diff --git a/app/network/page.tsx b/app/network/page.tsx
index 434e757..4c812e9 100644
--- a/app/network/page.tsx
+++ b/app/network/page.tsx
@@ -1,14 +1,58 @@
'use client';
-import React from 'react';
+import { Suspense } from 'react';
+import { useSearchParams } from 'next/navigation';
import { LightClientSyncIndicator } from '@/src/components/network/LightClientSyncIndicator';
import { NetworkGraph } from '@/src/components/network/NetworkGraph';
+import { NodeList } from '@/src/components/network/NodeList';
+import type { NetworkNode } from '@/src/types/node';
+
+const DEMO_NODES: NetworkNode[] = [
+ {
+ id: 'demo-1',
+ displayName: 'Aurora Validator 🚀',
+ description: 'High-uptime node in the EU region, operated by Aurora Labs. A & B tested.',
+ location: 'Frankfurt, DE',
+ contactEmail: 'ops@aurora.example',
+ websiteUrl: 'https://aurora.example',
+ },
+ {
+ id: 'demo-2',
+ displayName: '日本ノード',
+ description: 'Tokyo-based validator. 高可用性のノードです。',
+ location: '東京',
+ websiteUrl: 'http://jp-node.example',
+ },
+];
+
+function NodeDirectory() {
+ const params = useSearchParams();
+ const injectedName = params.get('name');
+ const injectedDescription = params.get('description');
+ const injectedWebsite = params.get('website');
+
+ const nodes: NetworkNode[] =
+ injectedName !== null || injectedDescription !== null || injectedWebsite !== null
+ ? [
+ {
+ id: 'injected',
+ displayName: injectedName ?? 'Injected Node',
+ description: injectedDescription ?? '',
+ location: 'unknown',
+ websiteUrl: injectedWebsite ?? undefined,
+ },
+ ...DEMO_NODES,
+ ]
+ : DEMO_NODES;
+
+ return ;
+}
export default function NetworkStatus() {
return (
Network Status
-
+
Validator topology
@@ -22,6 +66,16 @@ export default function NetworkStatus() {
+
+
+ Node directory
+
+ Operator-supplied labels and descriptions are sanitized before rendering.
+
+ Loading... }>
+
+
+
);
diff --git a/e2e/node-xss-regression.spec.ts b/e2e/node-xss-regression.spec.ts
new file mode 100644
index 0000000..522b39c
--- /dev/null
+++ b/e2e/node-xss-regression.spec.ts
@@ -0,0 +1,58 @@
+import { expect, test } from '@playwright/test'
+
+// Regression for issue #9: operator-supplied node fields must never execute as
+// HTML/JS. We inject malicious values through the URL (standing in for the
+// configuration API) and assert nothing executes.
+test.describe('Node field XSS regression (#9)', () => {
+ test('img/onerror payload in displayName does not execute', async ({ page }) => {
+ const dialogs: string[] = []
+ page.on('dialog', async (dialog) => {
+ dialogs.push(dialog.message())
+ await dialog.dismiss()
+ })
+
+ const payload = 'Acme Node'
+ await page.goto(`/network?name=${encodeURIComponent(payload)}`)
+
+ const card = page.getByTestId('node-card-injected')
+ await expect(card).toBeVisible()
+
+ // No injected element and no script execution.
+ await page.waitForTimeout(500)
+ expect(dialogs).toHaveLength(0)
+ expect(await card.locator('img').count()).toBe(0)
+
+ // The display name renders as sanitized plain text.
+ const name = await card.getByTestId('node-display-name').innerText()
+ expect(name).toContain('AcmeNode')
+ expect(name).not.toContain('<')
+ expect(name).not.toContain('onerror')
+ })
+
+ test('script payload in description is neutralized', async ({ page }) => {
+ const dialogs: string[] = []
+ page.on('dialog', async (dialog) => {
+ dialogs.push(dialog.message())
+ await dialog.dismiss()
+ })
+
+ const payload = '">'
+ await page.goto(`/network?description=${encodeURIComponent(payload)}`)
+
+ const card = page.getByTestId('node-card-injected')
+ await expect(card).toBeVisible()
+
+ await page.waitForTimeout(500)
+ expect(dialogs).toHaveLength(0)
+ expect(await card.locator('script').count()).toBe(0)
+ const description = await card.getByTestId('node-description').innerText()
+ expect(description).not.toContain('<')
+ })
+
+ test('sends a Content-Security-Policy with object-src none', async ({ page }) => {
+ const response = await page.goto('/network')
+ const csp = response?.headers()['content-security-policy'] ?? ''
+ expect(csp).toContain("object-src 'none'")
+ expect(csp).toContain('script-src')
+ })
+})
diff --git a/eslint.config.mjs b/eslint.config.mjs
index a7079f7..a20b791 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -3,15 +3,23 @@ import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import colorThemePlugin from "./src/eslint-plugin-color-theme.mjs";
+const DANGER_MESSAGE =
+ "dangerouslySetInnerHTML is banned (stored-XSS risk). Render sanitized plain text with / sanitizeNodeField instead.";
+
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
plugins: {
- 'color-theme': colorThemePlugin,
+ "color-theme": colorThemePlugin,
},
rules: {
- 'color-theme/no-hardcoded-colors': 'warn',
+ "color-theme/no-hardcoded-colors": "warn",
+ "no-restricted-syntax": [
+ "error",
+ { selector: "JSXAttribute[name.name='dangerouslySetInnerHTML']", message: DANGER_MESSAGE },
+ { selector: "Property[key.name='dangerouslySetInnerHTML']", message: DANGER_MESSAGE },
+ ],
},
},
// Override default ignores of eslint-config-next.
diff --git a/next.config.ts b/next.config.ts
index 3359cfa..e13444a 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,39 @@
import type { NextConfig } from "next";
+// Content-Security-Policy as defense-in-depth for issue #9. The app is
+// statically pre-rendered, so a per-request nonce can't be embedded; inline
+// scripts (Next's hydration bootstrap) therefore require 'unsafe-inline'.
+// 'unsafe-eval' is added only in development for Turbopack HMR. The directives
+// the issue calls out are enforced in production: object-src 'none' blocks
+// plugins, and script-src is restricted to 'self' (no remote script origins).
+// `upgrade-insecure-requests` is omitted so http://localhost keeps working.
+const isProd = process.env.NODE_ENV === "production";
+const scriptSrc = isProd ? "'self' 'unsafe-inline'" : "'self' 'unsafe-inline' 'unsafe-eval'";
+
+const CONTENT_SECURITY_POLICY = [
+ "default-src 'self'",
+ `script-src ${scriptSrc}`,
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data: blob:",
+ "font-src 'self'",
+ "connect-src 'self' https: wss: ws:",
+ "object-src 'none'",
+ "base-uri 'self'",
+ "form-action 'self'",
+ "frame-ancestors 'none'",
+].join("; ");
+
const nextConfig: NextConfig = {
headers: async () => [
+ {
+ source: "/(.*)",
+ headers: [
+ { key: "Content-Security-Policy", value: CONTENT_SECURITY_POLICY },
+ { key: "X-Content-Type-Options", value: "nosniff" },
+ { key: "X-Frame-Options", value: "DENY" },
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
+ ],
+ },
{
source: "/sw.js",
headers: [
diff --git a/package-lock.json b/package-lock.json
index 3304da9..80b681f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.4.0",
+ "isomorphic-dompurify": "^2.36.0",
"next": "16.1.6",
"qrcode": "^1.5.4",
"react": "19.2.3",
@@ -30,11 +31,18 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
+ "fast-check": "^3.23.2",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^3.2.4"
}
},
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.31",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
+ "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
+ "license": "MIT"
+ },
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -48,6 +56,59 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.1.11",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
+ "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@csstools/css-calc": "^3.2.0",
+ "@csstools/css-color-parser": "^4.1.0",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "6.8.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
+ "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.1.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.2.6"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+ "version": "11.5.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
+ "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/generational-cache": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
+ "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "license": "MIT"
+ },
"node_modules/@axe-core/playwright": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz",
@@ -301,6 +362,152 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
+ "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
+ "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.8.tgz",
+ "integrity": "sha512-3chWb7PRLijpJpPIKkDxdu6IBeO5MrFACND57On0j8OPpc0wZibcGc3xAHrSEbOx/KDRyMHoIxGn0w1PhXMYHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.0.2",
+ "@csstools/css-calc": "^3.2.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz",
+ "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
@@ -920,6 +1127,23 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1665,7 +1889,7 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
@@ -2589,6 +2813,13 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz",
@@ -3626,6 +3857,15 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/bignumber.js": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.4.tgz",
@@ -3968,6 +4208,43 @@
"node": ">= 8"
}
},
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/cssstyle": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz",
+ "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==",
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.0.1",
+ "@csstools/css-syntax-patches-for-csstree": "^1.0.28",
+ "css-tree": "^3.1.0",
+ "lru-cache": "^11.2.6"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/cssstyle/node_modules/lru-cache": {
+ "version": "11.5.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
+ "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -3982,6 +4259,19 @@
"dev": true,
"license": "BSD-2-Clause"
},
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -4050,7 +4340,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -4073,6 +4362,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "license": "MIT"
+ },
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -4165,6 +4460,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dompurify": {
+ "version": "3.4.11",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
+ "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -4208,6 +4512,18 @@
"node": ">=10.13.0"
}
},
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-abstract": {
"version": "1.24.1",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
@@ -4924,6 +5240,29 @@
"node": ">=12.0.0"
}
},
+ "node_modules/fast-check": {
+ "version": "3.23.2",
+ "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
+ "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "pure-rand": "^6.1.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5441,6 +5780,40 @@
"hermes-estree": "0.25.1"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/http-proxy-agent/node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -5807,6 +6180,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "license": "MIT"
+ },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -5979,6 +6358,19 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/isomorphic-dompurify": {
+ "version": "2.36.0",
+ "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.36.0.tgz",
+ "integrity": "sha512-E8YkGyPY3a/U5s0WOoc8Ok+3SWL/33yn2IHCoxCFLBUUPVy9WGa++akJZFxQCcJIhI+UvYhbrbnTIFQkHKZbgA==",
+ "license": "MIT",
+ "dependencies": {
+ "dompurify": "^3.3.1",
+ "jsdom": "^28.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.5"
+ }
+ },
"node_modules/iterator.prototype": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@@ -6027,6 +6419,68 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsdom": {
+ "version": "28.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
+ "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
+ "license": "MIT",
+ "dependencies": {
+ "@acemir/cssom": "^0.9.31",
+ "@asamuzakjp/dom-selector": "^6.8.1",
+ "@bramus/specificity": "^2.4.2",
+ "@exodus/bytes": "^1.11.0",
+ "cssstyle": "^6.0.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "parse5": "^8.0.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.0",
+ "undici": "^7.21.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/jsdom/node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -6468,6 +6922,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "license": "CC0-1.0"
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -6542,7 +7002,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -6887,6 +7346,18 @@
"node": ">=6"
}
},
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -7065,12 +7536,28 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
+ "node_modules/pure-rand": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
+ "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@@ -7200,6 +7687,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
@@ -7382,6 +7878,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -7911,6 +8419,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "license": "MIT"
+ },
"node_modules/tailwindcss": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@@ -8024,6 +8538,24 @@
"node": ">=14.0.0"
}
},
+ "node_modules/tldts": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.4.tgz",
+ "integrity": "sha512-kFXFK7O4WPextIUAOk8qtnw9dxR9UIXP9CjuH1cTBVBZMDeQcUPgr/IazGiw1B0Yiw5L75gHLWeW4iD793r90g==",
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.4"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.4.tgz",
+ "integrity": "sha512-vwVLJVvvpslm7vqAH7+XNj/neA/Ynq7DT2EEcMuwc5YzN5XaMyRAqxwU+uX3azZ1FQtB2gvrvnLnAEkvYlVdfg==",
+ "license": "MIT"
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -8037,6 +8569,30 @@
"node": ">=8.0"
}
},
+ "node_modules/tough-cookie": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
+ "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/ts-api-utils": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
@@ -8243,6 +8799,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/undici": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
+ "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -8556,6 +9121,50 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -8708,6 +9317,21 @@
"node": ">=8"
}
},
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "license": "MIT"
+ },
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
diff --git a/package.json b/package.json
index b949926..876ba5c 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "verinode-frontend",
"version": "0.1.0",
"private": true,
- "description": "Frontend for VeriNode — decentralized savings circles (ROSCA) protocol on Stellar Soroban",
+ "description": "Frontend for VeriNode — decentralized savings circles (ROSCA) protocol on Stellar Soroban",
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -22,6 +22,7 @@
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.4.0",
+ "isomorphic-dompurify": "^2.36.0",
"next": "16.1.6",
"qrcode": "^1.5.4",
"react": "19.2.3",
@@ -40,6 +41,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
+ "fast-check": "^3.23.2",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^3.2.4"
diff --git a/src/components/network/NodeCard.tsx b/src/components/network/NodeCard.tsx
new file mode 100644
index 0000000..4496247
--- /dev/null
+++ b/src/components/network/NodeCard.tsx
@@ -0,0 +1,55 @@
+import { SafeText } from '@/src/components/shared/SafeText'
+import { safeUrl } from '@/src/utils/sanitize'
+import type { NetworkNode } from '@/src/types/node'
+
+/**
+ * Compact node card. Every operator-supplied string is rendered through
+ * (sanitized plain text); the website is rendered as a link only if
+ * it resolves to a safe http(s) URL.
+ */
+export function NodeCard({ node }: { node: NetworkNode }) {
+ const website = node.websiteUrl ? safeUrl(node.websiteUrl) : null
+
+ return (
+
+
+
+
+
+ {node.location && (
+
+
+
+ )}
+
+
+
+
+
+
+
+ {node.contactEmail && (
+
+
+
+ )}
+ {website ? (
+
+ {website}
+
+ ) : node.websiteUrl ? (
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/components/network/NodeDetailPanel.tsx b/src/components/network/NodeDetailPanel.tsx
new file mode 100644
index 0000000..8f412d4
--- /dev/null
+++ b/src/components/network/NodeDetailPanel.tsx
@@ -0,0 +1,60 @@
+import type { ReactNode } from 'react'
+import { SafeText } from '@/src/components/shared/SafeText'
+import { safeUrl } from '@/src/utils/sanitize'
+import type { NetworkNode } from '@/src/types/node'
+
+function Row({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+
{label}
+ {children}
+
+ )
+}
+
+/**
+ * Full node detail view. As with NodeCard, every operator-supplied field is
+ * rendered through ; the website is linked only when it is a safe
+ * http(s) URL.
+ */
+export function NodeDetailPanel({ node }: { node: NetworkNode }) {
+ const website = node.websiteUrl ? safeUrl(node.websiteUrl) : null
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {node.contactEmail && (
+
+
+
+ )}
+
+ {website ? (
+
+ {website}
+
+ ) : node.websiteUrl ? (
+
+ ) : (
+ —
+ )}
+
+
+
+ )
+}
diff --git a/src/components/network/NodeList.tsx b/src/components/network/NodeList.tsx
new file mode 100644
index 0000000..c792b93
--- /dev/null
+++ b/src/components/network/NodeList.tsx
@@ -0,0 +1,17 @@
+import { NodeCard } from '@/src/components/network/NodeCard'
+import type { NetworkNode } from '@/src/types/node'
+
+/** Renders a responsive grid of node cards. */
+export function NodeList({ nodes }: { nodes: NetworkNode[] }) {
+ if (nodes.length === 0) {
+ return No nodes to display.
+ }
+
+ return (
+
+ {nodes.map((node) => (
+
+ ))}
+
+ )
+}
diff --git a/src/components/shared/SafeText.tsx b/src/components/shared/SafeText.tsx
new file mode 100644
index 0000000..97e7212
--- /dev/null
+++ b/src/components/shared/SafeText.tsx
@@ -0,0 +1,30 @@
+import type { SanitizableNodeField } from '@/src/types/node'
+import { sanitizeNodeField, sanitizeText } from '@/src/utils/sanitize'
+
+interface SafeTextProps {
+ text: string | null | undefined
+ /** Truncate the sanitized output to this many characters (adds an ellipsis). */
+ maxLength?: number
+ /** Apply per-field sanitization (and its max length) when set. */
+ field?: SanitizableNodeField
+ className?: string
+}
+
+/**
+ * Renders untrusted operator text safely: it sanitizes the value (HTML stripped,
+ * control chars removed, XSS vectors neutralized) and renders the result as
+ * plain text inside a . When truncated, the full sanitized value is
+ * exposed via the `title` attribute. Never uses dangerouslySetInnerHTML.
+ */
+export function SafeText({ text, maxLength, field, className }: SafeTextProps) {
+ const clean = field ? sanitizeNodeField(text ?? '', field) : sanitizeText(text ?? '')
+
+ const isTruncated = typeof maxLength === 'number' && clean.length > maxLength
+ const display = isTruncated ? `${clean.slice(0, maxLength).trimEnd()}…` : clean
+
+ return (
+
+ {display}
+
+ )
+}
diff --git a/src/types/node.ts b/src/types/node.ts
new file mode 100644
index 0000000..0fcf095
--- /dev/null
+++ b/src/types/node.ts
@@ -0,0 +1,18 @@
+// Operator-configurable node fields. All string fields are untrusted input and
+// MUST be passed through sanitizeNodeField / before rendering.
+
+export interface NetworkNode {
+ id: string
+ displayName: string
+ description: string
+ location: string
+ contactEmail?: string
+ websiteUrl?: string
+}
+
+export type SanitizableNodeField =
+ | 'displayName'
+ | 'description'
+ | 'location'
+ | 'contactEmail'
+ | 'websiteUrl'
diff --git a/src/utils/sanitize.ts b/src/utils/sanitize.ts
new file mode 100644
index 0000000..a27a7dd
--- /dev/null
+++ b/src/utils/sanitize.ts
@@ -0,0 +1,105 @@
+// XSS sanitization for operator-configurable node identifier strings.
+//
+// Node displayName / description / location / contactEmail / websiteUrl are
+// arbitrary operator input rendered in every other operator's dashboard, so
+// they are prime stored-XSS vectors. Every such value must be passed through
+// sanitizeNodeField (or rendered via ) before display.
+//
+// React already escapes text it renders, so the *primary* defense is "never
+// use dangerouslySetInnerHTML" (enforced by ESLint). This module is the second
+// layer: it reduces input to plain text with no markup, control characters, or
+// protocol-based vectors, while preserving legitimate Unicode (emoji, CJK,
+// RTL) and common punctuation.
+
+import DOMPurify from 'isomorphic-dompurify'
+import type { SanitizableNodeField } from '@/src/types/node'
+
+export const FIELD_MAX_LENGTHS: Record = {
+ displayName: 50,
+ description: 500,
+ location: 120,
+ contactEmail: 254,
+ websiteUrl: 2048,
+}
+
+// C0 controls except TAB (U+0009) and LF (U+000A), plus DEL and the C1 block
+// (U+007F-U+009F). Printable Unicode — letters, numbers, punctuation, symbols,
+// emoji, CJK, RTL — is preserved. Built from an ASCII source string so no
+// literal control bytes live in this file.
+const CONTROL_CHARS = new RegExp('[\\u0000-\\u0008\\u000B-\\u001F\\u007F-\\u009F]', 'g')
+
+// DOMPurify entity-encodes text output (e.g. `&` -> `&`); decode the common
+// entities back to plain characters for display.
+const BASIC_ENTITIES: Array<[RegExp, string]> = [
+ [/</gi, '<'],
+ [/>/gi, '>'],
+ [/"/gi, '"'],
+ [/*39;/g, "'"],
+ [/*27;/gi, "'"],
+ [/ /gi, ' '],
+ [/&/gi, '&'], // last, so a decoded `&` is not re-interpreted
+]
+
+function decodeBasicEntities(input: string): string {
+ let out = input
+ for (const [pattern, replacement] of BASIC_ENTITIES) out = out.replace(pattern, replacement)
+ return out
+}
+
+// Defense-in-depth: after markup removal, neutralize any residual angle
+// brackets and protocol / event-handler tokens so the output can never carry an
+// executable vector even if rendered in an unexpected context. Applied to a
+// fixpoint because removing one token can splice two halves into a fresh one
+// (e.g. "javasjavascript:cript:" -> "javascript:").
+function neutralizeVectors(input: string): string {
+ let previous: string
+ let out = input
+ do {
+ previous = out
+ out = out
+ .replace(/[<>]/g, '')
+ .replace(/javascript:/gi, '')
+ .replace(/vbscript:/gi, '')
+ .replace(/data:\s*text\/html/gi, '')
+ .replace(/\bon\w+\s*=/gi, '')
+ } while (out !== previous)
+ return out
+}
+
+/**
+ * Reduce arbitrary input to safe plain text:
+ * 1. strip all HTML tags/attributes via DOMPurify (ALLOWED_TAGS/ATTR: [])
+ * 2. decode the basic entities DOMPurify re-encodes
+ * 3. normalize to Unicode NFC
+ * 4. remove control characters (keeping TAB and LF)
+ * 5. neutralize residual XSS vectors
+ * 6. collapse to a trimmed string
+ */
+export function sanitizeText(input: string): string {
+ if (typeof input !== 'string' || input.length === 0) return ''
+ const stripped = DOMPurify.sanitize(input, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] })
+ const decoded = decodeBasicEntities(stripped).normalize('NFC').replace(CONTROL_CHARS, '')
+ return neutralizeVectors(decoded).trim()
+}
+
+/** Sanitize and enforce the per-field maximum length. */
+export function sanitizeNodeField(input: string, field: SanitizableNodeField): string {
+ return sanitizeText(input).slice(0, FIELD_MAX_LENGTHS[field])
+}
+
+/**
+ * Return a safe, displayable URL for `websiteUrl`, or null. Only http(s) URLs
+ * survive — `javascript:`, `data:`, and malformed values are rejected, so the
+ * result is safe to use as an href.
+ */
+export function safeUrl(input: string): string | null {
+ const cleaned = sanitizeText(input)
+ if (cleaned.length === 0) return null
+ try {
+ const url = new URL(cleaned)
+ if (url.protocol === 'http:' || url.protocol === 'https:') return url.toString()
+ return null
+ } catch {
+ return null
+ }
+}
diff --git a/src/utils/tests/sanitize.test.ts b/src/utils/tests/sanitize.test.ts
new file mode 100644
index 0000000..830db3a
--- /dev/null
+++ b/src/utils/tests/sanitize.test.ts
@@ -0,0 +1,87 @@
+import fc from 'fast-check'
+import { describe, expect, it } from 'vitest'
+import { FIELD_MAX_LENGTHS, safeUrl, sanitizeNodeField, sanitizeText } from '../sanitize'
+
+const PAYLOADS = [
+ '',
+ ' ',
+ '',
+ '',
+ '',
+ '">',
+ 'javascript:alert(1)',
+ 'vbscript:msgbox(1)',
+ 'data:text/html,',
+ 'click ',
+ 'onerror=alert(1)',
+ 'java script:alert(1)',
+]
+
+// Interleave malicious payloads with arbitrary (incl. full-Unicode) text.
+const maliciousArb = fc
+ .array(fc.oneof(fc.constantFrom(...PAYLOADS), fc.string(), fc.fullUnicodeString()), {
+ minLength: 1,
+ maxLength: 6,
+ })
+ .map((parts) => parts.join(' '))
+
+describe('sanitizeNodeField — XSS property tests (#9)', () => {
+ it('output never contains an executable vector (500 random malicious strings)', () => {
+ fc.assert(
+ fc.property(maliciousArb, (input) => {
+ for (const field of ['displayName', 'description', 'websiteUrl'] as const) {
+ const out = sanitizeNodeField(input, field)
+ expect(out).not.toMatch(/[<>]/)
+ expect(out).not.toMatch(/javascript:/i)
+ expect(out).not.toMatch(/vbscript:/i)
+ expect(out).not.toMatch(/data:\s*text\/html/i)
+ expect(out).not.toMatch(/\bon\w+\s*=/i)
+ }
+ }),
+ { numRuns: 500 },
+ )
+ })
+
+ it('never exceeds the per-field maximum length', () => {
+ fc.assert(
+ fc.property(fc.string({ maxLength: 5000 }), (input) => {
+ expect(sanitizeNodeField(input, 'displayName').length).toBeLessThanOrEqual(
+ FIELD_MAX_LENGTHS.displayName,
+ )
+ expect(sanitizeNodeField(input, 'description').length).toBeLessThanOrEqual(
+ FIELD_MAX_LENGTHS.description,
+ )
+ }),
+ { numRuns: 200 },
+ )
+ })
+})
+
+describe('sanitizeText — known vectors and legitimate input', () => {
+ it('strips classic XSS vectors to safe text', () => {
+ expect(sanitizeText(' ')).toBe('')
+ expect(sanitizeText('hello')).toBe('hello')
+ expect(sanitizeText('">')).not.toMatch(/[<>]/)
+ })
+
+ it('preserves legitimate Unicode (emoji, CJK, RTL) and punctuation', () => {
+ expect(sanitizeText('Validator 日本語 🚀 مرحبا')).toBe('Validator 日本語 🚀 مرحبا')
+ expect(sanitizeText('Node #1 — A & B (eu-west)')).toBe('Node #1 — A & B (eu-west)')
+ })
+
+ it('removes control characters but keeps tab and newline', () => {
+ const withControls = `a${String.fromCharCode(0)}b${String.fromCharCode(7)}c${String.fromCharCode(127)}`
+ expect(sanitizeText(withControls)).toBe('abc')
+ expect(sanitizeText('line1\nline2\tend')).toBe('line1\nline2\tend')
+ })
+})
+
+describe('safeUrl', () => {
+ it('accepts http(s) and rejects dangerous protocols', () => {
+ expect(safeUrl('https://example.com')).toBe('https://example.com/')
+ expect(safeUrl('http://node.io/path')).toBe('http://node.io/path')
+ expect(safeUrl('javascript:alert(1)')).toBeNull()
+ expect(safeUrl('data:text/html,')).toBeNull()
+ expect(safeUrl('not a url')).toBeNull()
+ })
+})