mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-10 12:40:34 +02:00
release: prepare v0.9.7
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import baseConfig from './next.config';
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const peerConfig: NextConfig = {
|
||||
...baseConfig,
|
||||
distDir: '.next-peer-b',
|
||||
};
|
||||
|
||||
export default peerConfig;
|
||||
+34
-31
@@ -6,29 +6,19 @@ import type { NextConfig } from 'next';
|
||||
// so any URL baked in here ignores the runtime BACKEND_URL env var.
|
||||
|
||||
const skipTypecheck = process.env.NEXT_SKIP_TYPECHECK === '1';
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
|
||||
// Desktop packaging: set NEXT_OUTPUT=export to produce a static export
|
||||
// (frontend/out/) suitable for Tauri bundling and companion server hosting.
|
||||
// This disables API routes, middleware, and server-side image optimization —
|
||||
// all handled by the Tauri shell and companion server in packaged mode.
|
||||
// Default remains 'standalone' for the web deployment (Docker/Vercel).
|
||||
const isDesktopExport = process.env.NEXT_OUTPUT === 'export';
|
||||
|
||||
// CSP is now emitted dynamically by src/middleware.ts (Phase 5F-A) so that
|
||||
// each document response carries a unique per-request nonce. Non-CSP
|
||||
// security headers remain here because they are static and benefit from
|
||||
// next.config's catch-all source matcher.
|
||||
const securityHeaders = [
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
isDev
|
||||
? "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:"
|
||||
: "script-src 'self' 'unsafe-inline' blob:",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
isDev
|
||||
? "connect-src 'self' ws: wss: http://127.0.0.1:8000 http://127.0.0.1:8787 https:"
|
||||
: "connect-src 'self' ws: wss: https:",
|
||||
"font-src 'self' data:",
|
||||
"object-src 'none'",
|
||||
"worker-src 'self' blob:",
|
||||
"child-src 'self' blob:",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join('; '),
|
||||
},
|
||||
{
|
||||
key: 'Referrer-Policy',
|
||||
value: 'no-referrer',
|
||||
@@ -45,9 +35,18 @@ const securityHeaders = [
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['react-map-gl', 'maplibre-gl'],
|
||||
output: 'standalone',
|
||||
output: isDesktopExport ? 'export' : 'standalone',
|
||||
devIndicators: false,
|
||||
experimental: isDesktopExport
|
||||
? {
|
||||
webpackBuildWorker: false,
|
||||
parallelServerCompiles: false,
|
||||
parallelServerBuildTraces: false,
|
||||
workerThreads: false,
|
||||
}
|
||||
: undefined,
|
||||
images: {
|
||||
unoptimized: isDesktopExport,
|
||||
remotePatterns: [
|
||||
{ protocol: 'https', hostname: 'upload.wikimedia.org' },
|
||||
{ protocol: 'https', hostname: 'via.placeholder.com' },
|
||||
@@ -60,14 +59,18 @@ const nextConfig: NextConfig = {
|
||||
typescript: {
|
||||
ignoreBuildErrors: skipTypecheck,
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
...(!isDesktopExport
|
||||
? {
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+233
-18
@@ -1,21 +1,24 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.9.6",
|
||||
"version": "0.9.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.9.6",
|
||||
"version": "0.9.7",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucide-react": "^0.575.0",
|
||||
"maplibre-gl": "^4.7.1",
|
||||
"next": "16.1.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"satellite.js": "^6.0.2",
|
||||
"zod": "^4.3.6"
|
||||
@@ -2201,6 +2204,34 @@
|
||||
"tailwindcss": "4.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
|
||||
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-process": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
|
||||
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-updater": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz",
|
||||
"integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
@@ -3249,7 +3280,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -3259,7 +3289,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -3714,6 +3743,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001774",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
|
||||
@@ -3786,7 +3824,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -3799,7 +3836,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
@@ -4019,6 +4055,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"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",
|
||||
@@ -4090,6 +4135,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
@@ -5096,7 +5147,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -5753,7 +5803,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -7218,6 +7267,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -7248,7 +7306,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -7297,6 +7354,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -7434,6 +7500,141 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -7471,15 +7672,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.3"
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
@@ -7575,7 +7776,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -7591,6 +7791,12 @@
|
||||
"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",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -7816,6 +8022,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@@ -8177,7 +8389,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -8192,7 +8403,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string.prototype.includes": {
|
||||
@@ -8312,7 +8522,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -9527,6 +9736,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.20",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.9.6",
|
||||
"version": "0.9.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-all.cjs",
|
||||
"dev:frontend": "next dev",
|
||||
"dev:backend": "node ../start-backend.js",
|
||||
"build:privacy-core-wasm": "node scripts/build-privacy-core-wasm.cjs",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
@@ -13,19 +14,23 @@
|
||||
"format:check": "prettier --check .",
|
||||
"bundle:report": "node scripts/report-bundle-size.js",
|
||||
"test": "npm run test:ci",
|
||||
"test:runtime-assurance": "set NODE_OPTIONS=--require ./scripts/vite-no-net-use.cjs && vitest run --pool=threads src/__tests__/mesh/gateCompatDecryptUx.test.tsx src/__tests__/mesh/wormholeIdentityClientProfiles.test.ts src/__tests__/mesh/gateMessageSnapshot.test.ts src/__tests__/desktop/desktopBridgeBootstrapPreference.test.ts src/__tests__/desktop/backendEndpoint.test.ts src/__tests__/desktop/desktopControlRouting.test.ts",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "set NODE_OPTIONS=--require ./scripts/vite-no-net-use.cjs && vitest run --coverage --pool=threads",
|
||||
"test:ci": "set NODE_OPTIONS=--require ./scripts/vite-no-net-use.cjs && vitest run --pool=threads"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucide-react": "^0.575.0",
|
||||
"maplibre-gl": "^4.7.1",
|
||||
"next": "16.1.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"satellite.js": "^6.0.2",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const frontendDir = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(frontendDir, '..');
|
||||
const privacyCoreManifest = path.join(repoRoot, 'privacy-core', 'Cargo.toml');
|
||||
const outDir = path.join(frontendDir, 'src', 'mesh', 'privacyCoreWasm');
|
||||
const wasmPath = path.join(
|
||||
repoRoot,
|
||||
'privacy-core',
|
||||
'target',
|
||||
'wasm32-unknown-unknown',
|
||||
'release',
|
||||
'privacy_core.wasm',
|
||||
);
|
||||
|
||||
function run(bin, args) {
|
||||
execFileSync(bin, args, {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
run('rustup', ['target', 'add', 'wasm32-unknown-unknown']);
|
||||
run('cargo', ['build', '--target', 'wasm32-unknown-unknown', '--release', '--manifest-path', privacyCoreManifest]);
|
||||
run('wasm-bindgen', ['--target', 'web', '--out-dir', outDir, wasmPath]);
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Phase 5F-A: CSP nonce plumbing tests.
|
||||
*
|
||||
* Validates:
|
||||
* 1. Nonce appears in document CSP header
|
||||
* 2. Nonce differs across repeated requests
|
||||
* 3. next.config.ts no longer owns a static CSP header
|
||||
* 4. Middleware does not break API/static routes (matcher exclusion)
|
||||
* 5. Google Fonts domains are preserved in CSP
|
||||
* 6. Production CSP preserves required directives
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { middleware, config as middlewareConfig } from '@/middleware';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Call middleware with a fake document request and return the response. */
|
||||
function callMiddleware(path = '/') {
|
||||
const req = new NextRequest(`http://localhost${path}`, { method: 'GET' });
|
||||
return middleware(req);
|
||||
}
|
||||
|
||||
/** Extract the CSP header string from a middleware response. */
|
||||
function getCsp(path = '/'): string {
|
||||
return callMiddleware(path).headers.get('Content-Security-Policy') ?? '';
|
||||
}
|
||||
|
||||
/** Check whether the middleware matcher regex excludes a given path. */
|
||||
function matcherExcludes(path: string): boolean {
|
||||
const pattern = middlewareConfig.matcher[0];
|
||||
// Next.js wraps the matcher in ^/<pattern>$ for path matching.
|
||||
// We replicate the essential check: the negative-lookahead prefix groups.
|
||||
const re = new RegExp(`^${pattern}$`);
|
||||
// Strip leading '/' because the matcher pattern starts with '/'.
|
||||
return !re.test(path);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Nonce appears in document CSP header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('nonce in CSP header', () => {
|
||||
it('CSP header contains a nonce-<value> token in script-src', () => {
|
||||
const csp = getCsp();
|
||||
expect(csp).toMatch(/'nonce-[A-Za-z0-9+/=]+'/) ;
|
||||
});
|
||||
|
||||
it('nonce value is a base64-encoded UUID', () => {
|
||||
const csp = getCsp();
|
||||
const match = csp.match(/'nonce-([A-Za-z0-9+/=]+)'/);
|
||||
expect(match).not.toBeNull();
|
||||
const decoded = Buffer.from(match![1], 'base64').toString();
|
||||
// crypto.randomUUID() produces 8-4-4-4-12 hex with dashes
|
||||
expect(decoded).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-/);
|
||||
});
|
||||
|
||||
it('x-nonce request header is set on the response', () => {
|
||||
const res = callMiddleware();
|
||||
// NextResponse.next({ request: { headers } }) merges into request headers.
|
||||
// The CSP nonce in the header must match the one forwarded to server components.
|
||||
const csp = res.headers.get('Content-Security-Policy') ?? '';
|
||||
const nonceInCsp = csp.match(/'nonce-([A-Za-z0-9+/=]+)'/)?.[1];
|
||||
expect(nonceInCsp).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Nonce differs across repeated requests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('nonce uniqueness', () => {
|
||||
it('two sequential requests produce different nonces', () => {
|
||||
const csp1 = getCsp();
|
||||
const csp2 = getCsp();
|
||||
const nonce1 = csp1.match(/'nonce-([A-Za-z0-9+/=]+)'/)?.[1];
|
||||
const nonce2 = csp2.match(/'nonce-([A-Za-z0-9+/=]+)'/)?.[1];
|
||||
expect(nonce1).toBeTruthy();
|
||||
expect(nonce2).toBeTruthy();
|
||||
expect(nonce1).not.toBe(nonce2);
|
||||
});
|
||||
|
||||
it('ten requests produce ten distinct nonces', () => {
|
||||
const nonces = new Set<string>();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const csp = getCsp();
|
||||
const nonce = csp.match(/'nonce-([A-Za-z0-9+/=]+)'/)?.[1];
|
||||
expect(nonce).toBeTruthy();
|
||||
nonces.add(nonce!);
|
||||
}
|
||||
expect(nonces.size).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. next.config.ts no longer owns static CSP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('next.config.ts CSP removal', () => {
|
||||
it('securityHeaders in next.config does not include Content-Security-Policy', async () => {
|
||||
// Import the built config and inspect the headers callback.
|
||||
const nextConfig = (await import('../../../next.config')).default;
|
||||
const headerEntries = await nextConfig.headers!();
|
||||
const allHeaders = headerEntries.flatMap(
|
||||
(entry: { headers: { key: string; value: string }[] }) => entry.headers,
|
||||
);
|
||||
const cspHeaders = allHeaders.filter(
|
||||
(h: { key: string }) => h.key.toLowerCase() === 'content-security-policy',
|
||||
);
|
||||
expect(cspHeaders).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('non-CSP security headers are still present', async () => {
|
||||
const nextConfig = (await import('../../../next.config')).default;
|
||||
const headerEntries = await nextConfig.headers!();
|
||||
const allKeys = headerEntries
|
||||
.flatMap(
|
||||
(entry: { headers: { key: string; value: string }[] }) => entry.headers,
|
||||
)
|
||||
.map((h: { key: string }) => h.key);
|
||||
expect(allKeys).toContain('Referrer-Policy');
|
||||
expect(allKeys).toContain('X-Content-Type-Options');
|
||||
expect(allKeys).toContain('X-Frame-Options');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Middleware does not break API/static routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('middleware matcher exclusions', () => {
|
||||
it('excludes /api paths', () => {
|
||||
expect(matcherExcludes('/api/mesh/events')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes /_next/static paths', () => {
|
||||
expect(matcherExcludes('/_next/static/chunks/main.js')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes /_next/image paths', () => {
|
||||
expect(matcherExcludes('/_next/image?url=foo')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes /favicon.ico', () => {
|
||||
expect(matcherExcludes('/favicon.ico')).toBe(true);
|
||||
});
|
||||
|
||||
it('includes document paths like /', () => {
|
||||
expect(matcherExcludes('/')).toBe(false);
|
||||
});
|
||||
|
||||
it('includes document paths like /dashboard', () => {
|
||||
expect(matcherExcludes('/dashboard')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Google Fonts domains are preserved in CSP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Google Fonts domains in CSP', () => {
|
||||
it('style-src includes https://fonts.googleapis.com', () => {
|
||||
const csp = getCsp();
|
||||
expect(csp).toContain('https://fonts.googleapis.com');
|
||||
});
|
||||
|
||||
it('font-src includes https://fonts.gstatic.com', () => {
|
||||
const csp = getCsp();
|
||||
expect(csp).toContain('https://fonts.gstatic.com');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Production CSP directive completeness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('production CSP directive completeness', () => {
|
||||
const csp = getCsp();
|
||||
|
||||
it('has default-src self', () => {
|
||||
expect(csp).toContain("default-src 'self'");
|
||||
});
|
||||
|
||||
it('has script-src with nonce', () => {
|
||||
expect(csp).toMatch(/script-src [^;]*'nonce-/);
|
||||
});
|
||||
|
||||
it('has style-src with unsafe-inline and fonts.googleapis.com', () => {
|
||||
expect(csp).toMatch(/style-src [^;]*'unsafe-inline'/);
|
||||
expect(csp).toMatch(/style-src [^;]*https:\/\/fonts\.googleapis\.com/);
|
||||
});
|
||||
|
||||
it('has worker-src self blob:', () => {
|
||||
expect(csp).toContain("worker-src 'self' blob:");
|
||||
});
|
||||
|
||||
it('has child-src self blob:', () => {
|
||||
expect(csp).toContain("child-src 'self' blob:");
|
||||
});
|
||||
|
||||
it('has img-src with self data: blob: https:', () => {
|
||||
expect(csp).toContain("img-src 'self' data: blob: https:");
|
||||
});
|
||||
|
||||
it('has connect-src with self', () => {
|
||||
expect(csp).toMatch(/connect-src 'self'/);
|
||||
});
|
||||
|
||||
it('has object-src none', () => {
|
||||
expect(csp).toContain("object-src 'none'");
|
||||
});
|
||||
|
||||
it('has frame-ancestors none', () => {
|
||||
expect(csp).toContain("frame-ancestors 'none'");
|
||||
});
|
||||
|
||||
it('has base-uri self', () => {
|
||||
expect(csp).toContain("base-uri 'self'");
|
||||
});
|
||||
|
||||
it('has form-action self', () => {
|
||||
expect(csp).toContain("form-action 'self'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Phase 5F-B: Production script-src unsafe-inline removal tests.
|
||||
*
|
||||
* Validates:
|
||||
* 1. Production CSP omits script-src 'unsafe-inline'
|
||||
* 2. Dev CSP retains 'unsafe-inline' and 'unsafe-eval'
|
||||
* 3. Unchanged directives (style-src, font-src, worker-src, etc.) intact
|
||||
* 4. API/static route exclusions remain intact
|
||||
* 5. isDev is evaluated per-request (not cached at module load)
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { middleware, config as middlewareConfig } from '@/middleware';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function callMiddleware(path = '/') {
|
||||
const req = new NextRequest(`http://localhost${path}`, { method: 'GET' });
|
||||
return middleware(req);
|
||||
}
|
||||
|
||||
function getCsp(path = '/'): string {
|
||||
return callMiddleware(path).headers.get('Content-Security-Policy') ?? '';
|
||||
}
|
||||
|
||||
/** Extract a single CSP directive by name. */
|
||||
function getDirective(name: string, csp?: string): string {
|
||||
const full = csp ?? getCsp();
|
||||
const re = new RegExp(`${name}\\s+([^;]+)`);
|
||||
return re.exec(full)?.[1]?.trim() ?? '';
|
||||
}
|
||||
|
||||
function matcherExcludes(path: string): boolean {
|
||||
const pattern = middlewareConfig.matcher[0];
|
||||
const re = new RegExp(`^${pattern}$`);
|
||||
return !re.test(path);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Production CSP omits script-src 'unsafe-inline'
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('production script-src hardening', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('production script-src does NOT contain unsafe-inline', () => {
|
||||
const scriptSrc = getDirective('script-src');
|
||||
expect(scriptSrc).not.toContain("'unsafe-inline'");
|
||||
});
|
||||
|
||||
it('production script-src does NOT contain unsafe-eval', () => {
|
||||
const scriptSrc = getDirective('script-src');
|
||||
expect(scriptSrc).not.toContain("'unsafe-eval'");
|
||||
});
|
||||
|
||||
it('production script-src contains nonce', () => {
|
||||
const scriptSrc = getDirective('script-src');
|
||||
expect(scriptSrc).toMatch(/'nonce-[A-Za-z0-9+/=]+'/);
|
||||
});
|
||||
|
||||
it('production script-src contains self and blob:', () => {
|
||||
const scriptSrc = getDirective('script-src');
|
||||
expect(scriptSrc).toContain("'self'");
|
||||
expect(scriptSrc).toContain('blob:');
|
||||
});
|
||||
|
||||
it('production connect-src uses restricted set', () => {
|
||||
const connectSrc = getDirective('connect-src');
|
||||
expect(connectSrc).not.toContain('http://127.0.0.1:8000');
|
||||
expect(connectSrc).not.toContain('http://127.0.0.1:8787');
|
||||
expect(connectSrc).toContain("'self'");
|
||||
expect(connectSrc).toContain('wss:');
|
||||
expect(connectSrc).toContain('https:');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Dev CSP retains required dev allowances
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('dev script-src allowances', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('dev script-src contains unsafe-inline', () => {
|
||||
const scriptSrc = getDirective('script-src');
|
||||
expect(scriptSrc).toContain("'unsafe-inline'");
|
||||
});
|
||||
|
||||
it('dev script-src contains unsafe-eval', () => {
|
||||
const scriptSrc = getDirective('script-src');
|
||||
expect(scriptSrc).toContain("'unsafe-eval'");
|
||||
});
|
||||
|
||||
it('dev script-src still contains nonce', () => {
|
||||
const scriptSrc = getDirective('script-src');
|
||||
expect(scriptSrc).toMatch(/'nonce-[A-Za-z0-9+/=]+'/);
|
||||
});
|
||||
|
||||
it('dev connect-src includes localhost backends', () => {
|
||||
const connectSrc = getDirective('connect-src');
|
||||
expect(connectSrc).toContain('http://127.0.0.1:8000');
|
||||
expect(connectSrc).toContain('http://127.0.0.1:8787');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Unchanged directives remain intact across both modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('unchanged directives in production', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('style-src preserves unsafe-inline and Google Fonts', () => {
|
||||
const styleSrc = getDirective('style-src');
|
||||
expect(styleSrc).toContain("'unsafe-inline'");
|
||||
expect(styleSrc).toContain('https://fonts.googleapis.com');
|
||||
});
|
||||
|
||||
it('font-src preserves data: and fonts.gstatic.com', () => {
|
||||
const fontSrc = getDirective('font-src');
|
||||
expect(fontSrc).toContain('data:');
|
||||
expect(fontSrc).toContain('https://fonts.gstatic.com');
|
||||
});
|
||||
|
||||
it('worker-src self blob:', () => {
|
||||
expect(getCsp()).toContain("worker-src 'self' blob:");
|
||||
});
|
||||
|
||||
it('child-src self blob:', () => {
|
||||
expect(getCsp()).toContain("child-src 'self' blob:");
|
||||
});
|
||||
|
||||
it('img-src self data: blob: https:', () => {
|
||||
expect(getCsp()).toContain("img-src 'self' data: blob: https:");
|
||||
});
|
||||
|
||||
it('object-src none', () => {
|
||||
expect(getCsp()).toContain("object-src 'none'");
|
||||
});
|
||||
|
||||
it('frame-ancestors none', () => {
|
||||
expect(getCsp()).toContain("frame-ancestors 'none'");
|
||||
});
|
||||
|
||||
it('base-uri self', () => {
|
||||
expect(getCsp()).toContain("base-uri 'self'");
|
||||
});
|
||||
|
||||
it('form-action self', () => {
|
||||
expect(getCsp()).toContain("form-action 'self'");
|
||||
});
|
||||
|
||||
it('default-src self', () => {
|
||||
expect(getCsp()).toContain("default-src 'self'");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. API/static route exclusions remain intact
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('matcher exclusions unchanged', () => {
|
||||
it('excludes /api paths', () => {
|
||||
expect(matcherExcludes('/api/mesh/events')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes /_next/static paths', () => {
|
||||
expect(matcherExcludes('/_next/static/chunks/main.js')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes /_next/image paths', () => {
|
||||
expect(matcherExcludes('/_next/image?url=foo')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes /favicon.ico', () => {
|
||||
expect(matcherExcludes('/favicon.ico')).toBe(true);
|
||||
});
|
||||
|
||||
it('includes document paths', () => {
|
||||
expect(matcherExcludes('/')).toBe(false);
|
||||
expect(matcherExcludes('/dashboard')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. isDev evaluated per-request (not cached at module load)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('per-request environment evaluation', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('switching NODE_ENV between calls changes script-src', () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
const prodScriptSrc = getDirective('script-src');
|
||||
expect(prodScriptSrc).not.toContain("'unsafe-inline'");
|
||||
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
const devScriptSrc = getDirective('script-src');
|
||||
expect(devScriptSrc).toContain("'unsafe-inline'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Tests for getBackendEndpoint() — the runtime-resolved API endpoint
|
||||
* displayed in "Connect" modals for external tool configuration.
|
||||
*
|
||||
* Verifies:
|
||||
* - Returns window.location.origin when window is available
|
||||
* - Returns fallback when window is undefined (SSR)
|
||||
* - Does NOT hardcode :8000
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
describe('getBackendEndpoint', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
// Restore window if we deleted it
|
||||
if (!globalThis.window && originalWindow) {
|
||||
globalThis.window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
it('returns window.location.origin in browser context', async () => {
|
||||
// Default test environment (jsdom) has window defined
|
||||
const { getBackendEndpoint } = await import('@/lib/backendEndpoint');
|
||||
const result = getBackendEndpoint();
|
||||
expect(result).toBe(window.location.origin);
|
||||
expect(result).not.toContain(':8000');
|
||||
});
|
||||
|
||||
it('does not hardcode port 8000', async () => {
|
||||
const { getBackendEndpoint } = await import('@/lib/backendEndpoint');
|
||||
const result = getBackendEndpoint();
|
||||
// The result should be derived from window.location, not a hardcoded backend port
|
||||
expect(result).not.toMatch(/:8000$/);
|
||||
});
|
||||
|
||||
it('returns http://localhost:8000 fallback when window is undefined (SSR)', async () => {
|
||||
// Temporarily remove window to simulate SSR
|
||||
// @ts-expect-error — intentionally removing window for SSR simulation
|
||||
delete globalThis.window;
|
||||
const { getBackendEndpoint } = await import('@/lib/backendEndpoint');
|
||||
const result = getBackendEndpoint();
|
||||
expect(result).toBe('http://localhost:8000');
|
||||
// Restore
|
||||
globalThis.window = originalWindow;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Tests for companion section failure visibility in SettingsPanel.
|
||||
*
|
||||
* Verifies the contract from P6E:
|
||||
* - When fetchCompanionStatus rejects, the companion section still renders
|
||||
* (with an unavailable/error state) rather than silently disappearing
|
||||
* - When fetchCompanionStatus succeeds, normal controls are shown
|
||||
*
|
||||
* These are logic-level tests for the state transitions — they do NOT
|
||||
* render SettingsPanel (which has deep dependency chains). They verify
|
||||
* the decision logic: companionLoadFailed drives visibility.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('companion section visibility contract', () => {
|
||||
it('companionAvailable && companionLoadFailed shows the section (failure path)', () => {
|
||||
// Simulates the render guard: {companionAvailable && (companion || companionLoadFailed) && (...)}
|
||||
const companionAvailable = true;
|
||||
const companion = null; // fetch failed, no status loaded
|
||||
const companionLoadFailed = true;
|
||||
|
||||
const shouldRender = companionAvailable && (companion || companionLoadFailed);
|
||||
expect(shouldRender).toBeTruthy();
|
||||
});
|
||||
|
||||
it('companionAvailable && companion shows the section (success path)', () => {
|
||||
const companionAvailable = true;
|
||||
const companion = { enabled: false, url: null, warning: 'Reduced trust.' };
|
||||
const companionLoadFailed = false;
|
||||
|
||||
const shouldRender = companionAvailable && (companion || companionLoadFailed);
|
||||
expect(shouldRender).toBeTruthy();
|
||||
});
|
||||
|
||||
it('section is hidden when not on desktop (companionAvailable=false)', () => {
|
||||
const companionAvailable = false;
|
||||
const companion = null;
|
||||
const companionLoadFailed = true;
|
||||
|
||||
const shouldRender = companionAvailable && (companion || companionLoadFailed);
|
||||
expect(shouldRender).toBeFalsy();
|
||||
});
|
||||
|
||||
it('section is hidden before first load attempt (no status, no failure)', () => {
|
||||
const companionAvailable = true;
|
||||
const companion = null;
|
||||
const companionLoadFailed = false;
|
||||
|
||||
const shouldRender = companionAvailable && (companion || companionLoadFailed);
|
||||
expect(shouldRender).toBeFalsy();
|
||||
});
|
||||
|
||||
it('controls are hidden when companion is null (failure mode shows only error)', () => {
|
||||
// In the rendered UI: {companion && (<buttons>)} — buttons hidden when companion is null
|
||||
const companion = null;
|
||||
const companionLoadFailed = true;
|
||||
|
||||
const showControls = !!companion;
|
||||
expect(showControls).toBe(false);
|
||||
// But the section itself should still render
|
||||
expect(companionLoadFailed).toBe(true);
|
||||
});
|
||||
|
||||
it('warning box only renders when companion has a warning string', () => {
|
||||
// {companion?.warning && (<warning>)}
|
||||
const companionNull = null;
|
||||
const companionWithWarning = { enabled: true, url: 'http://127.0.0.1:9876', warning: 'Reduced trust.' };
|
||||
const companionNoWarning = { enabled: false, url: null, warning: '' };
|
||||
|
||||
expect(companionNull?.warning).toBeFalsy();
|
||||
expect(companionWithWarning?.warning).toBeTruthy();
|
||||
expect(companionNoWarning?.warning).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ShadowbrokerDesktopRuntime } from '@/lib/desktopBridge';
|
||||
|
||||
describe('desktopBridgeBootstrapPreference', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
// Clean window globals before each test
|
||||
delete (window as Record<string, unknown>).__SHADOWBROKER_DESKTOP__;
|
||||
delete (window as Record<string, unknown>).__SHADOWBROKER_LOCAL_CONTROL__;
|
||||
});
|
||||
|
||||
it('prefers a pre-installed native runtime over the HTTP shim', async () => {
|
||||
const nativeInvoke = vi.fn().mockResolvedValue({ ok: true });
|
||||
const nativeRuntime: ShadowbrokerDesktopRuntime = {
|
||||
invokeLocalControl: nativeInvoke,
|
||||
getNativeControlAuditReport: () => ({
|
||||
totalEvents: 0,
|
||||
totalRecorded: 0,
|
||||
recent: [],
|
||||
byOutcome: {},
|
||||
}),
|
||||
clearNativeControlAuditReport: vi.fn(),
|
||||
};
|
||||
|
||||
// Simulate Tauri injection: set __SHADOWBROKER_DESKTOP__ before bootstrap
|
||||
window.__SHADOWBROKER_DESKTOP__ = nativeRuntime;
|
||||
|
||||
const { bootstrapDesktopControlBridge } = await import('@/lib/desktopBridge');
|
||||
const installed = bootstrapDesktopControlBridge();
|
||||
|
||||
expect(installed).toBe(true);
|
||||
// The bridge should have been derived from the native runtime
|
||||
expect(window.__SHADOWBROKER_LOCAL_CONTROL__).toBeDefined();
|
||||
expect(window.__SHADOWBROKER_LOCAL_CONTROL__!.invoke).toBeDefined();
|
||||
|
||||
// Invoke through the bridge — should delegate to the native runtime
|
||||
await window.__SHADOWBROKER_LOCAL_CONTROL__!.invoke!({
|
||||
command: 'wormhole.status',
|
||||
payload: undefined,
|
||||
});
|
||||
expect(nativeInvoke).toHaveBeenCalledTimes(1);
|
||||
expect(nativeInvoke.mock.calls[0][0]).toBe('wormhole.status');
|
||||
});
|
||||
|
||||
it('does not install bridge when no native runtime and shim env is off', async () => {
|
||||
// No __SHADOWBROKER_DESKTOP__ and NEXT_PUBLIC_ENABLE_DESKTOP_BRIDGE_SHIM != '1'
|
||||
const originalEnv = process.env.NEXT_PUBLIC_ENABLE_DESKTOP_BRIDGE_SHIM;
|
||||
process.env.NEXT_PUBLIC_ENABLE_DESKTOP_BRIDGE_SHIM = '0';
|
||||
|
||||
const { bootstrapDesktopControlBridge } = await import('@/lib/desktopBridge');
|
||||
const installed = bootstrapDesktopControlBridge();
|
||||
|
||||
expect(installed).toBe(false);
|
||||
expect(window.__SHADOWBROKER_LOCAL_CONTROL__).toBeUndefined();
|
||||
|
||||
process.env.NEXT_PUBLIC_ENABLE_DESKTOP_BRIDGE_SHIM = originalEnv;
|
||||
});
|
||||
|
||||
it('falls back to HTTP shim when no native runtime and shim env is on', async () => {
|
||||
const originalEnv = process.env.NEXT_PUBLIC_ENABLE_DESKTOP_BRIDGE_SHIM;
|
||||
process.env.NEXT_PUBLIC_ENABLE_DESKTOP_BRIDGE_SHIM = '1';
|
||||
|
||||
const { bootstrapDesktopControlBridge } = await import('@/lib/desktopBridge');
|
||||
const installed = bootstrapDesktopControlBridge();
|
||||
|
||||
expect(installed).toBe(true);
|
||||
// Bridge installed via shim
|
||||
expect(window.__SHADOWBROKER_LOCAL_CONTROL__).toBeDefined();
|
||||
// __SHADOWBROKER_DESKTOP__ is the HTTP-backed shim
|
||||
expect(window.__SHADOWBROKER_DESKTOP__).toBeDefined();
|
||||
expect(window.__SHADOWBROKER_DESKTOP__!.invokeLocalControl).toBeDefined();
|
||||
expect(window.__SHADOWBROKER_DESKTOP__!.getNativeControlAuditReport).toBeDefined();
|
||||
expect(window.__SHADOWBROKER_DESKTOP__!.clearNativeControlAuditReport).toBeDefined();
|
||||
|
||||
process.env.NEXT_PUBLIC_ENABLE_DESKTOP_BRIDGE_SHIM = originalEnv;
|
||||
});
|
||||
|
||||
it('native runtime audit report is accessible through getDesktopNativeControlAuditReport', async () => {
|
||||
const auditReport = {
|
||||
totalEvents: 5,
|
||||
totalRecorded: 5,
|
||||
recent: [],
|
||||
byOutcome: { allowed: 5 },
|
||||
};
|
||||
const nativeRuntime: ShadowbrokerDesktopRuntime = {
|
||||
invokeLocalControl: vi.fn().mockResolvedValue({}),
|
||||
getNativeControlAuditReport: () => auditReport,
|
||||
clearNativeControlAuditReport: vi.fn(),
|
||||
};
|
||||
window.__SHADOWBROKER_DESKTOP__ = nativeRuntime;
|
||||
|
||||
const { bootstrapDesktopControlBridge, getDesktopNativeControlAuditReport } =
|
||||
await import('@/lib/desktopBridge');
|
||||
bootstrapDesktopControlBridge();
|
||||
|
||||
const report = getDesktopNativeControlAuditReport();
|
||||
expect(report).toEqual(auditReport);
|
||||
});
|
||||
|
||||
it('localControlFetch routes through native bridge when available', async () => {
|
||||
const nativeInvoke = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, status: 'connected' });
|
||||
const nativeRuntime: ShadowbrokerDesktopRuntime = {
|
||||
invokeLocalControl: nativeInvoke,
|
||||
};
|
||||
window.__SHADOWBROKER_DESKTOP__ = nativeRuntime;
|
||||
|
||||
const { installDesktopControlBridge } = await import('@/lib/desktopBridge');
|
||||
installDesktopControlBridge(nativeRuntime);
|
||||
|
||||
const { localControlFetch } = await import('@/lib/localControlTransport');
|
||||
const response = await localControlFetch('/api/wormhole/status');
|
||||
const data = await response.json();
|
||||
|
||||
expect(nativeInvoke).toHaveBeenCalledTimes(1);
|
||||
expect(nativeInvoke.mock.calls[0][0]).toBe('wormhole.status');
|
||||
expect(data).toEqual({ ok: true, status: 'connected' });
|
||||
});
|
||||
|
||||
it('localControlFetch falls back to fetch when no bridge is present', async () => {
|
||||
// No bridge installed — localControlFetch should use regular fetch
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const { localControlFetch } = await import('@/lib/localControlTransport');
|
||||
await localControlFetch('/api/wormhole/status');
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const callUrl = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(callUrl).toContain('/api/wormhole/status');
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('Rust handler coverage matches the full contract command set', async () => {
|
||||
const { DESKTOP_CONTROL_COMMANDS } = await import(
|
||||
'@/lib/desktopControlContract'
|
||||
);
|
||||
// This test documents the expected contract size.
|
||||
// If the contract grows, the Rust handlers.rs must be updated to match.
|
||||
expect(DESKTOP_CONTROL_COMMANDS.length).toBe(27);
|
||||
|
||||
// Verify every command has a corresponding HTTP route
|
||||
const { commandToHttpRequest } = await import(
|
||||
'@/lib/desktopControlRouting'
|
||||
);
|
||||
for (const command of DESKTOP_CONTROL_COMMANDS) {
|
||||
// Gate commands need a payload with gate_id
|
||||
const payload = command.includes('gate')
|
||||
? { gate_id: 'test-gate', plaintext: 'x', reason: 'test', label: 'l', persona_id: 'p', epoch: 0, ciphertext: '', nonce: '', sender_ref: '', messages: [] }
|
||||
: undefined;
|
||||
expect(() => commandToHttpRequest(command, payload)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('native runtime forwards meta to invokeLocalControl', async () => {
|
||||
const nativeInvoke = vi.fn().mockResolvedValue({ ok: true });
|
||||
const nativeRuntime: ShadowbrokerDesktopRuntime = {
|
||||
invokeLocalControl: nativeInvoke,
|
||||
};
|
||||
window.__SHADOWBROKER_DESKTOP__ = nativeRuntime;
|
||||
|
||||
const { installDesktopControlBridge } = await import('@/lib/desktopBridge');
|
||||
installDesktopControlBridge(nativeRuntime);
|
||||
|
||||
await window.__SHADOWBROKER_LOCAL_CONTROL__!.invoke!({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
payload: { gate_id: 'infonet', reason: 'operator_reset' },
|
||||
meta: {
|
||||
capability: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(nativeInvoke).toHaveBeenCalledTimes(1);
|
||||
expect(nativeInvoke.mock.calls[0][0]).toBe('wormhole.gate.key.rotate');
|
||||
expect(nativeInvoke.mock.calls[0][1]).toEqual({ gate_id: 'infonet', reason: 'operator_reset' });
|
||||
// meta must not be dropped
|
||||
const receivedMeta = nativeInvoke.mock.calls[0][2];
|
||||
expect(receivedMeta).toBeDefined();
|
||||
expect(receivedMeta).toEqual(expect.objectContaining({
|
||||
capability: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('native runtime rejects on capability mismatch and records audit', async () => {
|
||||
const { controlCommandCapability } = await import('@/lib/desktopControlContract');
|
||||
const nativeInvoke = vi.fn().mockResolvedValue({ ok: true });
|
||||
const auditEntries: unknown[] = [];
|
||||
|
||||
// Simulate a Tauri-like runtime that checks capability mismatch
|
||||
const nativeRuntime: ShadowbrokerDesktopRuntime = {
|
||||
invokeLocalControl: async (command, payload, meta) => {
|
||||
const expectedCap = controlCommandCapability(command!);
|
||||
if (meta?.capability && meta.capability !== expectedCap) {
|
||||
auditEntries.push({
|
||||
command,
|
||||
expectedCapability: expectedCap,
|
||||
declaredCapability: meta.capability,
|
||||
outcome: 'capability_mismatch',
|
||||
});
|
||||
throw new Error(
|
||||
`native_control_capability_mismatch:${meta.capability}:${expectedCap}`,
|
||||
);
|
||||
}
|
||||
return nativeInvoke(command, payload, meta);
|
||||
},
|
||||
getNativeControlAuditReport: () => ({
|
||||
totalEvents: auditEntries.length,
|
||||
totalRecorded: auditEntries.length,
|
||||
recent: [],
|
||||
byOutcome: { capability_mismatch: auditEntries.length },
|
||||
}),
|
||||
clearNativeControlAuditReport: vi.fn(),
|
||||
};
|
||||
window.__SHADOWBROKER_DESKTOP__ = nativeRuntime;
|
||||
|
||||
const { installDesktopControlBridge } = await import('@/lib/desktopBridge');
|
||||
installDesktopControlBridge(nativeRuntime);
|
||||
|
||||
// Declare wrong capability: 'settings' for a wormhole_gate_key command
|
||||
await expect(
|
||||
window.__SHADOWBROKER_LOCAL_CONTROL__!.invoke!({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
payload: { gate_id: 'infonet', reason: 'test' },
|
||||
meta: { capability: 'settings' },
|
||||
}),
|
||||
).rejects.toThrow('native_control_capability_mismatch');
|
||||
|
||||
expect(nativeInvoke).not.toHaveBeenCalled();
|
||||
expect(auditEntries).toHaveLength(1);
|
||||
expect(auditEntries[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
declaredCapability: 'settings',
|
||||
expectedCapability: 'wormhole_gate_key',
|
||||
outcome: 'capability_mismatch',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('native runtime denies on profile enforcement and records audit', async () => {
|
||||
const { controlCommandCapability, sessionProfileCapabilities } = await import(
|
||||
'@/lib/desktopControlContract'
|
||||
);
|
||||
const nativeInvoke = vi.fn().mockResolvedValue({ ok: true });
|
||||
const auditEntries: unknown[] = [];
|
||||
|
||||
// Simulate a Tauri-like runtime that enforces session profiles
|
||||
const nativeRuntime: ShadowbrokerDesktopRuntime = {
|
||||
invokeLocalControl: async (command, payload, meta) => {
|
||||
const expectedCap = controlCommandCapability(command!);
|
||||
const profile = meta?.sessionProfileHint;
|
||||
const profileCaps = profile ? sessionProfileCapabilities(profile) : [];
|
||||
const profileAllows =
|
||||
!profile || profileCaps.length === 0 || profileCaps.includes(expectedCap);
|
||||
const enforced = Boolean(meta?.enforceProfileHint && profile);
|
||||
if (!profileAllows && enforced) {
|
||||
auditEntries.push({
|
||||
command,
|
||||
expectedCapability: expectedCap,
|
||||
sessionProfile: profile,
|
||||
outcome: 'profile_denied',
|
||||
});
|
||||
throw new Error(
|
||||
`native_control_profile_mismatch:${profile}:${expectedCap}`,
|
||||
);
|
||||
}
|
||||
return nativeInvoke(command, payload, meta);
|
||||
},
|
||||
getNativeControlAuditReport: () => ({
|
||||
totalEvents: auditEntries.length,
|
||||
totalRecorded: auditEntries.length,
|
||||
recent: [],
|
||||
byOutcome: { profile_denied: auditEntries.length },
|
||||
}),
|
||||
clearNativeControlAuditReport: vi.fn(),
|
||||
};
|
||||
window.__SHADOWBROKER_DESKTOP__ = nativeRuntime;
|
||||
|
||||
const { installDesktopControlBridge } = await import('@/lib/desktopBridge');
|
||||
installDesktopControlBridge(nativeRuntime);
|
||||
|
||||
// settings_only profile cannot access wormhole_gate_key commands
|
||||
await expect(
|
||||
window.__SHADOWBROKER_LOCAL_CONTROL__!.invoke!({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
payload: { gate_id: 'infonet', reason: 'test' },
|
||||
meta: {
|
||||
capability: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'settings_only',
|
||||
enforceProfileHint: true,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('native_control_profile_mismatch');
|
||||
|
||||
expect(nativeInvoke).not.toHaveBeenCalled();
|
||||
expect(auditEntries).toHaveLength(1);
|
||||
expect(auditEntries[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
expectedCapability: 'wormhole_gate_key',
|
||||
sessionProfile: 'settings_only',
|
||||
outcome: 'profile_denied',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('native runtime audit report populates on allowed invocations', async () => {
|
||||
let auditCallCount = 0;
|
||||
const nativeInvoke = vi.fn().mockResolvedValue({ ok: true });
|
||||
const nativeRuntime: ShadowbrokerDesktopRuntime = {
|
||||
invokeLocalControl: async (command, payload, meta) => {
|
||||
auditCallCount++;
|
||||
return nativeInvoke(command, payload, meta);
|
||||
},
|
||||
getNativeControlAuditReport: () => ({
|
||||
totalEvents: auditCallCount,
|
||||
totalRecorded: auditCallCount,
|
||||
recent: [],
|
||||
byOutcome: { allowed: auditCallCount },
|
||||
}),
|
||||
clearNativeControlAuditReport: () => { auditCallCount = 0; },
|
||||
};
|
||||
window.__SHADOWBROKER_DESKTOP__ = nativeRuntime;
|
||||
|
||||
const { installDesktopControlBridge, getDesktopNativeControlAuditReport } =
|
||||
await import('@/lib/desktopBridge');
|
||||
installDesktopControlBridge(nativeRuntime);
|
||||
|
||||
await window.__SHADOWBROKER_LOCAL_CONTROL__!.invoke!({
|
||||
command: 'wormhole.status',
|
||||
payload: undefined,
|
||||
});
|
||||
await window.__SHADOWBROKER_LOCAL_CONTROL__!.invoke!({
|
||||
command: 'settings.privacy.get',
|
||||
payload: undefined,
|
||||
});
|
||||
|
||||
const report = getDesktopNativeControlAuditReport();
|
||||
expect(report).toBeDefined();
|
||||
expect(report!.totalEvents).toBe(2);
|
||||
expect(report!.totalRecorded).toBe(2);
|
||||
expect(report!.byOutcome).toEqual(expect.objectContaining({ allowed: 2 }));
|
||||
});
|
||||
|
||||
it('injected JS capability map covers every contract command', async () => {
|
||||
const { DESKTOP_CONTROL_COMMANDS, controlCommandCapability } = await import(
|
||||
'@/lib/desktopControlContract'
|
||||
);
|
||||
// The capability map embedded in the Tauri injected JS (main.rs) must
|
||||
// cover every command. This test verifies the TypeScript contract source
|
||||
// which the JS map mirrors — if the contract grows, this catches drift.
|
||||
for (const command of DESKTOP_CONTROL_COMMANDS) {
|
||||
const cap = controlCommandCapability(command);
|
||||
expect(cap).toBeDefined();
|
||||
expect(typeof cap).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('profile capability resolution matches between TS and expected Tauri JS tables', async () => {
|
||||
const { sessionProfileCapabilities } = await import(
|
||||
'@/lib/desktopControlContract'
|
||||
);
|
||||
// Verify the profile→capabilities mapping that the Tauri JS mirrors
|
||||
const profiles = [
|
||||
'full_app', 'gate_observe', 'gate_operator', 'wormhole_runtime', 'settings_only',
|
||||
] as const;
|
||||
for (const profile of profiles) {
|
||||
const caps = sessionProfileCapabilities(profile);
|
||||
expect(Array.isArray(caps)).toBe(true);
|
||||
expect(caps.length).toBeGreaterThan(0);
|
||||
}
|
||||
// Specific assertions matching the Tauri JS table
|
||||
expect(sessionProfileCapabilities('settings_only')).toEqual(['settings']);
|
||||
expect(sessionProfileCapabilities('gate_observe')).toEqual(['wormhole_gate_content']);
|
||||
expect(sessionProfileCapabilities('full_app')).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Tests for the desktop companion mode helper (desktopCompanion.ts).
|
||||
*
|
||||
* Validates runtime detection, Tauri invoke delegation, and browser-mode
|
||||
* fallback behavior without requiring a live Tauri runtime.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
isNativeDesktop,
|
||||
companionStatus,
|
||||
companionEnable,
|
||||
companionDisable,
|
||||
companionOpenBrowser,
|
||||
} from '@/lib/desktopCompanion';
|
||||
|
||||
const MOCK_STATUS = {
|
||||
enabled: false,
|
||||
url: null,
|
||||
warning: 'Browser companion mode is less secure than the native desktop window.',
|
||||
};
|
||||
|
||||
const MOCK_ENABLED = {
|
||||
enabled: true,
|
||||
url: 'http://127.0.0.1:3000',
|
||||
warning: 'Browser companion mode is less secure than the native desktop window.',
|
||||
};
|
||||
|
||||
describe('desktopCompanion', () => {
|
||||
afterEach(() => {
|
||||
// Clean up __TAURI__ mock
|
||||
delete (window as Record<string, unknown>).__TAURI__;
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Runtime detection
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('isNativeDesktop', () => {
|
||||
it('returns false when __TAURI__ is not present', () => {
|
||||
expect(isNativeDesktop()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when __TAURI__.core.invoke is missing', () => {
|
||||
(window as Record<string, unknown>).__TAURI__ = { core: {} };
|
||||
expect(isNativeDesktop()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when __TAURI__.core.invoke is available', () => {
|
||||
(window as Record<string, unknown>).__TAURI__ = { core: { invoke: vi.fn() } };
|
||||
expect(isNativeDesktop()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Browser-mode fallback (all commands return null)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('browser mode (no Tauri)', () => {
|
||||
it('companionStatus returns null', async () => {
|
||||
expect(await companionStatus()).toBeNull();
|
||||
});
|
||||
|
||||
it('companionEnable returns null', async () => {
|
||||
expect(await companionEnable()).toBeNull();
|
||||
});
|
||||
|
||||
it('companionDisable returns null', async () => {
|
||||
expect(await companionDisable()).toBeNull();
|
||||
});
|
||||
|
||||
it('companionOpenBrowser returns null', async () => {
|
||||
expect(await companionOpenBrowser()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Desktop mode (mocked Tauri invoke)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('desktop mode (Tauri present)', () => {
|
||||
let mockInvoke: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockInvoke = vi.fn();
|
||||
(window as Record<string, unknown>).__TAURI__ = { core: { invoke: mockInvoke } };
|
||||
});
|
||||
|
||||
it('companionStatus invokes companion_status', async () => {
|
||||
mockInvoke.mockResolvedValue(MOCK_STATUS);
|
||||
const result = await companionStatus();
|
||||
expect(mockInvoke).toHaveBeenCalledWith('companion_status');
|
||||
expect(result).toEqual(MOCK_STATUS);
|
||||
});
|
||||
|
||||
it('companionEnable invokes companion_enable', async () => {
|
||||
mockInvoke.mockResolvedValue(MOCK_ENABLED);
|
||||
const result = await companionEnable();
|
||||
expect(mockInvoke).toHaveBeenCalledWith('companion_enable');
|
||||
expect(result).toEqual(MOCK_ENABLED);
|
||||
});
|
||||
|
||||
it('companionDisable invokes companion_disable', async () => {
|
||||
mockInvoke.mockResolvedValue(MOCK_STATUS);
|
||||
const result = await companionDisable();
|
||||
expect(mockInvoke).toHaveBeenCalledWith('companion_disable');
|
||||
expect(result).toEqual(MOCK_STATUS);
|
||||
});
|
||||
|
||||
it('companionOpenBrowser invokes companion_open_browser', async () => {
|
||||
mockInvoke.mockResolvedValue(MOCK_ENABLED);
|
||||
const result = await companionOpenBrowser();
|
||||
expect(mockInvoke).toHaveBeenCalledWith('companion_open_browser');
|
||||
expect(result).toEqual(MOCK_ENABLED);
|
||||
});
|
||||
|
||||
it('propagates Tauri invoke errors', async () => {
|
||||
mockInvoke.mockRejectedValue(new Error('companion_not_enabled'));
|
||||
await expect(companionOpenBrowser()).rejects.toThrow('companion_not_enabled');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Status shape
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('CompanionStatus shape', () => {
|
||||
it('disabled status has null url', () => {
|
||||
expect(MOCK_STATUS.enabled).toBe(false);
|
||||
expect(MOCK_STATUS.url).toBeNull();
|
||||
expect(MOCK_STATUS.warning).toBeTruthy();
|
||||
});
|
||||
|
||||
it('enabled status has a url', () => {
|
||||
expect(MOCK_ENABLED.enabled).toBe(true);
|
||||
expect(MOCK_ENABLED.url).toBe('http://127.0.0.1:3000');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
describeNativeControlError,
|
||||
extractNativeGateResyncTarget,
|
||||
extractGateTargetRef,
|
||||
} from '../../lib/desktopControlContract';
|
||||
|
||||
@@ -22,6 +23,12 @@ describe('extractGateTargetRef', () => {
|
||||
expect(extractGateTargetRef('wormhole.gate.proof', { gate_id: 'alpha' })).toBe('alpha');
|
||||
});
|
||||
|
||||
it('extracts gate_id from gate state resync payload', () => {
|
||||
expect(extractGateTargetRef('wormhole.gate.state.resync', { gate_id: 'alpha' })).toBe(
|
||||
'alpha',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts gate_id from gate message post payload', () => {
|
||||
expect(
|
||||
extractGateTargetRef('wormhole.gate.message.post', { gate_id: 'ops', plaintext: 'hi' }),
|
||||
@@ -86,9 +93,30 @@ describe('describeNativeControlError', () => {
|
||||
expect(describeNativeControlError(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('describes native gate resync requirement errors', () => {
|
||||
expect(
|
||||
describeNativeControlError('native_gate_state_resync_required:ops'),
|
||||
).toContain('gate resync');
|
||||
});
|
||||
|
||||
it('handles plain string errors', () => {
|
||||
expect(
|
||||
describeNativeControlError('native_control_profile_mismatch:foo'),
|
||||
).toContain('Denied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractNativeGateResyncTarget', () => {
|
||||
it('extracts the gate id from native resync-required errors', () => {
|
||||
expect(extractNativeGateResyncTarget('native_gate_state_resync_required:ops')).toBe('ops');
|
||||
expect(extractNativeGateResyncTarget(new Error('native_gate_state_resync_required:infonet'))).toBe(
|
||||
'infonet',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for unrelated errors', () => {
|
||||
expect(extractNativeGateResyncTarget(new Error('network_error'))).toBeNull();
|
||||
expect(extractNativeGateResyncTarget('native_control_profile_mismatch:foo')).toBeNull();
|
||||
expect(extractNativeGateResyncTarget(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,11 @@ describe('desktopControlRouting', () => {
|
||||
path: '/api/wormhole/gate/infonet/key',
|
||||
method: 'GET',
|
||||
});
|
||||
expect(commandToHttpRequest('wormhole.gate.state.resync', { gate_id: 'infonet' })).toEqual({
|
||||
path: '/api/wormhole/gate/state/export',
|
||||
method: 'POST',
|
||||
payload: { gate_id: 'infonet' },
|
||||
});
|
||||
expect(commandToHttpRequest('settings.news.reset')).toEqual({
|
||||
path: '/api/settings/news-feeds/reset',
|
||||
method: 'POST',
|
||||
@@ -27,11 +32,12 @@ describe('desktopControlRouting', () => {
|
||||
commandToHttpRequest('wormhole.gate.message.post', {
|
||||
gate_id: 'ops',
|
||||
plaintext: 'hello',
|
||||
reply_to: 'evt-parent-1',
|
||||
}),
|
||||
).toEqual({
|
||||
path: '/api/wormhole/gate/message/post',
|
||||
method: 'POST',
|
||||
payload: { gate_id: 'ops', plaintext: 'hello' },
|
||||
payload: { gate_id: 'ops', plaintext: 'hello', reply_to: 'evt-parent-1' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,8 +59,18 @@ describe('desktopControlRouting', () => {
|
||||
JSON.stringify({ gate_id: 'infonet', reason: 'operator_reset' }),
|
||||
),
|
||||
).toEqual({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
payload: { gate_id: 'infonet', reason: 'operator_reset' },
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
payload: { gate_id: 'infonet', reason: 'operator_reset' },
|
||||
});
|
||||
expect(
|
||||
httpRequestToInvokeRequest(
|
||||
'/api/wormhole/gate/state/export',
|
||||
'POST',
|
||||
JSON.stringify({ gate_id: 'infonet' }),
|
||||
),
|
||||
).toEqual({
|
||||
command: 'wormhole.gate.state.resync',
|
||||
payload: { gate_id: 'infonet' },
|
||||
});
|
||||
expect(
|
||||
httpRequestToInvokeRequest(
|
||||
@@ -96,6 +112,16 @@ describe('desktopControlRouting', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
httpRequestToInvokeRequest(
|
||||
'/api/wormhole/gate/message/post',
|
||||
'POST',
|
||||
JSON.stringify({ gate_id: 'ops', plaintext: 'hello', reply_to: 'evt-parent-2' }),
|
||||
),
|
||||
).toEqual({
|
||||
command: 'wormhole.gate.message.post',
|
||||
payload: { gate_id: 'ops', plaintext: 'hello', reply_to: 'evt-parent-2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for unsupported paths', () => {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Tests for native desktop protected-settings readiness bypass.
|
||||
*
|
||||
* Verifies that:
|
||||
* - isNativeProtectedSettingsReady() correctly reflects native bridge presence
|
||||
* - When the native bridge is present, admin-session browser flow is bypassed
|
||||
* - When no native bridge, existing admin-session gating is preserved
|
||||
*
|
||||
* These are unit tests for the extracted readiness logic. They do NOT render
|
||||
* SettingsPanel — they test the decision layer that SettingsPanel depends on.
|
||||
* Full component render coverage is not claimed.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock the bridge detection used by nativeProtectedSettings
|
||||
const mockHasLocalControlBridge = vi.fn();
|
||||
|
||||
vi.mock('@/lib/localControlTransport', () => ({
|
||||
hasLocalControlBridge: () => mockHasLocalControlBridge(),
|
||||
canInvokeLocalControl: vi.fn(),
|
||||
localControlFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock adminSession to verify it's bypassed or called as expected
|
||||
const mockHasAdminSession = vi.fn();
|
||||
const mockPrimeAdminSession = vi.fn();
|
||||
|
||||
vi.mock('@/lib/adminSession', () => ({
|
||||
hasAdminSession: () => mockHasAdminSession(),
|
||||
primeAdminSession: (...args: unknown[]) => mockPrimeAdminSession(...args),
|
||||
clearAdminSession: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('isNativeProtectedSettingsReady', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockHasLocalControlBridge.mockReset();
|
||||
});
|
||||
|
||||
it('returns true when native local-control bridge is present', async () => {
|
||||
mockHasLocalControlBridge.mockReturnValue(true);
|
||||
const mod = await import('@/lib/nativeProtectedSettings');
|
||||
expect(mod.isNativeProtectedSettingsReady()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no native bridge (browser mode)', async () => {
|
||||
mockHasLocalControlBridge.mockReturnValue(false);
|
||||
const mod = await import('@/lib/nativeProtectedSettings');
|
||||
expect(mod.isNativeProtectedSettingsReady()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('controlPlaneFetch admin-session bypass with native bridge', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockHasLocalControlBridge.mockReset();
|
||||
mockHasAdminSession.mockReset();
|
||||
mockPrimeAdminSession.mockReset();
|
||||
});
|
||||
|
||||
it('skips primeAdminSession when native bridge handles the request', async () => {
|
||||
mockHasLocalControlBridge.mockReturnValue(true);
|
||||
// canInvokeLocalControl is mocked to be truthy via the mock setup
|
||||
const { canInvokeLocalControl, localControlFetch } = await import(
|
||||
'@/lib/localControlTransport'
|
||||
);
|
||||
(canInvokeLocalControl as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
(localControlFetch as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/lib/controlPlane');
|
||||
await mod.controlPlaneFetch('/api/settings/api-keys', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(mockPrimeAdminSession).not.toHaveBeenCalled();
|
||||
expect(localControlFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still primes admin session in browser mode (no native bridge)', async () => {
|
||||
mockHasLocalControlBridge.mockReturnValue(false);
|
||||
const { canInvokeLocalControl, localControlFetch } = await import(
|
||||
'@/lib/localControlTransport'
|
||||
);
|
||||
(canInvokeLocalControl as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
(localControlFetch as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
mockPrimeAdminSession.mockResolvedValue(undefined);
|
||||
|
||||
const mod = await import('@/lib/controlPlane');
|
||||
await mod.controlPlaneFetch('/api/settings/api-keys', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(mockPrimeAdminSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('native protected-settings readiness in SettingsPanel context', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockHasLocalControlBridge.mockReset();
|
||||
mockHasAdminSession.mockReset();
|
||||
});
|
||||
|
||||
it('native bridge present: hasAdminSession is NOT called by refreshAdminSession logic', async () => {
|
||||
mockHasLocalControlBridge.mockReturnValue(true);
|
||||
// The helper returns true — SettingsPanel's refreshAdminSession should
|
||||
// short-circuit and never call hasAdminSession()
|
||||
const mod = await import('@/lib/nativeProtectedSettings');
|
||||
expect(mod.isNativeProtectedSettingsReady()).toBe(true);
|
||||
// hasAdminSession should not have been called
|
||||
expect(mockHasAdminSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('no native bridge: hasAdminSession is the readiness source', async () => {
|
||||
mockHasLocalControlBridge.mockReturnValue(false);
|
||||
const mod = await import('@/lib/nativeProtectedSettings');
|
||||
expect(mod.isNativeProtectedSettingsReady()).toBe(false);
|
||||
// In this scenario, SettingsPanel would call hasAdminSession() — we
|
||||
// verify the helper returns false so the browser flow is used.
|
||||
mockHasAdminSession.mockResolvedValue(true);
|
||||
const adminMod = await import('@/lib/adminSession');
|
||||
const ready = await adminMod.hasAdminSession();
|
||||
expect(ready).toBe(true);
|
||||
expect(mockHasAdminSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
classifyUpdateRuntime,
|
||||
getDesktopUpdateContext,
|
||||
getPreferredManualUpdateUrl,
|
||||
getUpdateAction,
|
||||
pickDesktopInstallerUrl,
|
||||
type GitHubLatestRelease,
|
||||
} from '@/lib/updateRuntime';
|
||||
|
||||
const RELEASE: GitHubLatestRelease = {
|
||||
html_url: 'https://github.com/BigBodyCobain/Shadowbroker/releases/tag/v0.9.7',
|
||||
assets: [
|
||||
{ name: 'ShadowBroker_0.9.7_x64_en-US.msi', browser_download_url: 'https://example.test/windows.msi' },
|
||||
{ name: 'ShadowBroker_0.9.7_x64-setup.exe', browser_download_url: 'https://example.test/windows-setup.exe' },
|
||||
{ name: 'ShadowBroker_0.9.7_aarch64.dmg', browser_download_url: 'https://example.test/macos.dmg' },
|
||||
{ name: 'ShadowBroker_0.9.7_amd64.AppImage', browser_download_url: 'https://example.test/linux.AppImage' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('updateRuntime', () => {
|
||||
afterEach(() => {
|
||||
delete (window as Record<string, unknown>).__TAURI__;
|
||||
});
|
||||
|
||||
describe('getDesktopUpdateContext', () => {
|
||||
it('returns null when Tauri is not present', async () => {
|
||||
expect(await getDesktopUpdateContext()).toBeNull();
|
||||
});
|
||||
|
||||
it('invokes desktop_update_context when Tauri is present', async () => {
|
||||
const invoke = vi.fn().mockResolvedValue({
|
||||
mode: 'packaged',
|
||||
platform: 'windows',
|
||||
is_packaged_build: true,
|
||||
backend_mode: 'managed',
|
||||
owns_local_backend: true,
|
||||
});
|
||||
(window as Record<string, unknown>).__TAURI__ = { core: { invoke } };
|
||||
|
||||
const result = await getDesktopUpdateContext();
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('desktop_update_context');
|
||||
expect(result).toEqual({
|
||||
mode: 'packaged',
|
||||
platform: 'windows',
|
||||
is_packaged_build: true,
|
||||
backend_mode: 'managed',
|
||||
owns_local_backend: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime classification', () => {
|
||||
it('classifies browser mode when no desktop context exists', () => {
|
||||
expect(classifyUpdateRuntime(null)).toBe('browser');
|
||||
expect(getUpdateAction('browser')).toBe('auto_apply');
|
||||
});
|
||||
|
||||
it('classifies desktop dev mode as auto-apply', () => {
|
||||
expect(
|
||||
classifyUpdateRuntime({
|
||||
mode: 'dev',
|
||||
platform: 'windows',
|
||||
is_packaged_build: false,
|
||||
}),
|
||||
).toBe('desktop_dev');
|
||||
expect(getUpdateAction('desktop_dev')).toBe('auto_apply');
|
||||
});
|
||||
|
||||
it('classifies packaged desktop mode as manual-download', () => {
|
||||
expect(
|
||||
classifyUpdateRuntime({
|
||||
mode: 'packaged',
|
||||
platform: 'windows',
|
||||
is_packaged_build: true,
|
||||
}),
|
||||
).toBe('desktop_packaged');
|
||||
expect(getUpdateAction('desktop_packaged')).toBe('manual_download');
|
||||
});
|
||||
});
|
||||
|
||||
describe('installer asset selection', () => {
|
||||
it('prefers msi installers on windows', () => {
|
||||
expect(pickDesktopInstallerUrl(RELEASE, 'windows')).toBe('https://example.test/windows.msi');
|
||||
});
|
||||
|
||||
it('prefers dmg installers on macos', () => {
|
||||
expect(pickDesktopInstallerUrl(RELEASE, 'macos')).toBe('https://example.test/macos.dmg');
|
||||
});
|
||||
|
||||
it('prefers appimage installers on linux', () => {
|
||||
expect(pickDesktopInstallerUrl(RELEASE, 'linux')).toBe('https://example.test/linux.AppImage');
|
||||
});
|
||||
|
||||
it('falls back to the release page when no platform asset matches', () => {
|
||||
expect(getPreferredManualUpdateUrl(RELEASE, 'desktop_packaged', 'unknown')).toBe(
|
||||
RELEASE.html_url,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses release page for non-packaged runtimes', () => {
|
||||
expect(getPreferredManualUpdateUrl(RELEASE, 'browser', 'windows')).toBe(RELEASE.html_url);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Sprint 4D behavioral tests — MaplibreViewer CCTV proxy, subscription isolation,
|
||||
* and parent-owned interpolation.
|
||||
*
|
||||
* These tests exercise actual runtime logic:
|
||||
* 1. buildCctvProxyUrl — proxy construction with various URL inputs
|
||||
* 2. Popup components do not own keyed subscriptions or map lifecycle
|
||||
* 3. Parent-owned interpolation: ShipPopup receives pre-interpolated coords
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildCctvProxyUrl } from '@/lib/cctvProxy';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const POPUP_DIR = path.resolve(__dirname, '../../components/MaplibreViewer/popups');
|
||||
const COMP_DIR = path.resolve(__dirname, '../../components');
|
||||
|
||||
function readPopup(name: string): string {
|
||||
return fs.readFileSync(path.join(POPUP_DIR, name), 'utf-8');
|
||||
}
|
||||
|
||||
// ─── buildCctvProxyUrl runtime behavior ───────────────────────────────────
|
||||
|
||||
describe('MaplibreViewer behavior — buildCctvProxyUrl', () => {
|
||||
it('proxies http:// URLs through /api/cctv/media', () => {
|
||||
const result = buildCctvProxyUrl('http://example.com/stream.mjpg');
|
||||
expect(result).toBe('/api/cctv/media?url=http%3A%2F%2Fexample.com%2Fstream.mjpg');
|
||||
});
|
||||
|
||||
it('proxies https:// URLs through /api/cctv/media', () => {
|
||||
const result = buildCctvProxyUrl('https://cdn.dot.gov/cam/42.m3u8');
|
||||
expect(result).toBe('/api/cctv/media?url=https%3A%2F%2Fcdn.dot.gov%2Fcam%2F42.m3u8');
|
||||
});
|
||||
|
||||
it('passes through relative URLs unchanged', () => {
|
||||
expect(buildCctvProxyUrl('/local/stream.mp4')).toBe('/local/stream.mp4');
|
||||
});
|
||||
|
||||
it('passes through empty string unchanged', () => {
|
||||
expect(buildCctvProxyUrl('')).toBe('');
|
||||
});
|
||||
|
||||
it('passes through data: URIs unchanged', () => {
|
||||
expect(buildCctvProxyUrl('data:image/png;base64,abc')).toBe('data:image/png;base64,abc');
|
||||
});
|
||||
|
||||
it('correctly encodes special characters in URLs', () => {
|
||||
const url = 'http://cam.example.com/view?id=42&token=a b';
|
||||
const result = buildCctvProxyUrl(url);
|
||||
expect(result).toContain('/api/cctv/media?url=');
|
||||
// Decode and verify roundtrip
|
||||
const encoded = result.replace('/api/cctv/media?url=', '');
|
||||
expect(decodeURIComponent(encoded)).toBe(url);
|
||||
});
|
||||
|
||||
it('handles URLs with fragments and query params', () => {
|
||||
const url = 'https://cam.example.com/stream#t=0?quality=hd';
|
||||
const result = buildCctvProxyUrl(url);
|
||||
expect(result).toContain('/api/cctv/media?url=');
|
||||
const encoded = result.replace('/api/cctv/media?url=', '');
|
||||
expect(decodeURIComponent(encoded)).toBe(url);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MaplibreViewer wiring — uses buildCctvProxyUrl ───────────────────────
|
||||
|
||||
describe('MaplibreViewer behavior — CCTV proxy wiring', () => {
|
||||
const viewer = fs.readFileSync(path.join(COMP_DIR, 'MaplibreViewer.tsx'), 'utf-8');
|
||||
|
||||
it('MaplibreViewer calls buildCctvProxyUrl(rawUrl) in CCTV section', () => {
|
||||
expect(viewer).toContain('buildCctvProxyUrl(rawUrl)');
|
||||
});
|
||||
|
||||
it('MaplibreViewer imports buildCctvProxyUrl from @/lib/cctvProxy', () => {
|
||||
expect(viewer).toMatch(
|
||||
/import\s*\{[^}]*buildCctvProxyUrl[^}]*\}\s*from\s+['"]@\/lib\/cctvProxy['"]/,
|
||||
);
|
||||
});
|
||||
|
||||
it('CCTV proxy URL is assigned to `url` and passed to CctvFullscreenModal', () => {
|
||||
const cctvSection = viewer.slice(
|
||||
viewer.indexOf("selectedEntity?.type === 'cctv'"),
|
||||
viewer.indexOf("selectedEntity?.type === 'cctv'") + 1600,
|
||||
);
|
||||
expect(cctvSection).toContain('const url = buildCctvProxyUrl(rawUrl)');
|
||||
expect(cctvSection).toContain('url={url}');
|
||||
expect(cctvSection).toContain('<CctvFullscreenModal');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Popup subscription isolation ─────────────────────────────────────────
|
||||
|
||||
describe('MaplibreViewer behavior — popup components have no keyed subscriptions', () => {
|
||||
const popupFiles = [
|
||||
'SatellitePopup.tsx',
|
||||
'ShipPopup.tsx',
|
||||
'SigintPopup.tsx',
|
||||
'MilitaryBasePopup.tsx',
|
||||
'RegionDossierPanel.tsx',
|
||||
];
|
||||
|
||||
const FORBIDDEN_HOOKS = [
|
||||
'useDataKeys',
|
||||
'useDataSnapshot',
|
||||
'useDataStore',
|
||||
'useImperativeSource',
|
||||
'useViewportBounds',
|
||||
'useInterpolation',
|
||||
];
|
||||
|
||||
for (const file of popupFiles) {
|
||||
const name = path.basename(file, '.tsx');
|
||||
it(`${name} does not import any data-store or map-lifecycle hooks`, () => {
|
||||
const content = readPopup(file);
|
||||
for (const hook of FORBIDDEN_HOOKS) {
|
||||
expect(content).not.toContain(hook);
|
||||
}
|
||||
});
|
||||
|
||||
it(`${name} does not reference mapRef or mapInitRef`, () => {
|
||||
const content = readPopup(file);
|
||||
expect(content).not.toContain('mapRef');
|
||||
expect(content).not.toContain('mapInitRef');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Parent-owned interpolation for popup positions ───────────────────────
|
||||
|
||||
describe('MaplibreViewer behavior — parent-owned interpolation feeds popup coords', () => {
|
||||
const viewer = fs.readFileSync(path.join(COMP_DIR, 'MaplibreViewer.tsx'), 'utf-8');
|
||||
|
||||
it('MaplibreViewer calls interpShip before passing coords to ShipPopup', () => {
|
||||
// Find the ship popup section
|
||||
const shipSection = viewer.slice(
|
||||
viewer.indexOf('{/* Ship / carrier click popup */}'),
|
||||
viewer.indexOf('{/* Ship / carrier click popup */}') + 800,
|
||||
);
|
||||
// interpShip must be called, and its result fed into ShipPopup props
|
||||
expect(shipSection).toContain('interpShip(ship)');
|
||||
expect(shipSection).toContain('longitude={iLng}');
|
||||
expect(shipSection).toContain('latitude={iLat}');
|
||||
});
|
||||
|
||||
it('ShipPopup receives longitude and latitude as props (not computing them)', () => {
|
||||
const shipPopup = readPopup('ShipPopup.tsx');
|
||||
expect(shipPopup).toContain('longitude: number');
|
||||
expect(shipPopup).toContain('latitude: number');
|
||||
// Must NOT contain interpolation logic
|
||||
expect(shipPopup).not.toContain('interpolatePosition');
|
||||
expect(shipPopup).not.toContain('interpShip');
|
||||
expect(shipPopup).not.toContain('useInterpolation');
|
||||
});
|
||||
|
||||
it('MaplibreViewer owns useInterpolation hook', () => {
|
||||
expect(viewer).toContain('useInterpolation');
|
||||
expect(viewer).toMatch(/interpShip/);
|
||||
expect(viewer).toMatch(/interpFlight/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Sprint 4C regression tests — MaplibreViewer decomposition boundary checks.
|
||||
*
|
||||
* These tests validate the frozen contract for MaplibreViewer decomposition:
|
||||
* 1. CctvFullscreenModal extracted to MaplibreViewer-local module
|
||||
* 2. Popup components extracted to MaplibreViewer/popups/
|
||||
* 3. CCTV proxy URL construction stays in MaplibreViewer (not in CctvFullscreenModal)
|
||||
* 4. Popup components receive explicit props (not parent-scope captures)
|
||||
* 5. Selection/dismissal: entity click → onClose dispatches onEntityClick(null)
|
||||
* 6. MaplibreViewer retains <Map>, mapRef, useImperativeSource, Source/Layer, useViewportBounds
|
||||
* 7. No keyed subscription regression (useDataKeys, not useDataSnapshot)
|
||||
* 8. No mega-hook extraction (no useMapController)
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const COMP_DIR = path.resolve(__dirname, '../../components');
|
||||
|
||||
function readComp(name: string): string {
|
||||
return fs.readFileSync(path.join(COMP_DIR, name), 'utf-8');
|
||||
}
|
||||
|
||||
// ─── CctvFullscreenModal extraction ────────────────────────────────────────
|
||||
|
||||
describe('MaplibreViewer decomposition — CctvFullscreenModal extraction', () => {
|
||||
it('CctvFullscreenModal is defined in its own MaplibreViewer-local module', () => {
|
||||
const modal = readComp('MaplibreViewer/CctvFullscreenModal.tsx');
|
||||
expect(modal).toMatch(/export\s+function\s+CctvFullscreenModal/);
|
||||
expect(modal).toContain('onClose');
|
||||
});
|
||||
|
||||
it('CctvFullscreenModal exports CctvFullscreenModalProps interface', () => {
|
||||
const modal = readComp('MaplibreViewer/CctvFullscreenModal.tsx');
|
||||
expect(modal).toMatch(/export\s+interface\s+CctvFullscreenModalProps/);
|
||||
expect(modal).toContain('url: string');
|
||||
expect(modal).toContain('mediaType: string');
|
||||
expect(modal).toContain('isVideo: boolean');
|
||||
expect(modal).toContain('cameraName: string');
|
||||
expect(modal).toContain('sourceAgency: string');
|
||||
expect(modal).toContain('cameraId: string');
|
||||
});
|
||||
|
||||
it('MaplibreViewer imports CctvFullscreenModal from extracted module', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
expect(viewer).toMatch(
|
||||
/import\s*\{.*CctvFullscreenModal.*\}\s*from\s+['"]@\/components\/MaplibreViewer\/CctvFullscreenModal['"]/,
|
||||
);
|
||||
});
|
||||
|
||||
it('MaplibreViewer no longer defines CctvFullscreenModal inline', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
expect(viewer).not.toMatch(/^function\s+CctvFullscreenModal\s*\(/m);
|
||||
});
|
||||
|
||||
it('CctvFullscreenModal does NOT contain proxy URL logic (stays in MaplibreViewer)', () => {
|
||||
const modal = readComp('MaplibreViewer/CctvFullscreenModal.tsx');
|
||||
// Proxy construction (/api/cctv/media?url=) must stay in MaplibreViewer
|
||||
expect(modal).not.toContain('/api/cctv/media');
|
||||
expect(modal).not.toContain('encodeURIComponent');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CCTV proxy URL behavior ───────────────────────────────────────────────
|
||||
|
||||
describe('MaplibreViewer decomposition — CCTV proxy URL behavior', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
|
||||
it('CCTV section delegates proxy URL construction to buildCctvProxyUrl', () => {
|
||||
expect(viewer).toContain('buildCctvProxyUrl(rawUrl)');
|
||||
expect(viewer).toMatch(
|
||||
/import\s*\{[^}]*buildCctvProxyUrl[^}]*\}\s*from\s+['"]@\/lib\/cctvProxy['"]/,
|
||||
);
|
||||
});
|
||||
|
||||
it('CCTV section passes proxied URL to CctvFullscreenModal', () => {
|
||||
// The pattern: url={url} where url is the proxied URL
|
||||
const cctvSection = viewer.slice(
|
||||
viewer.indexOf("selectedEntity?.type === 'cctv'"),
|
||||
viewer.indexOf('</CctvFullscreenModal>') !== -1
|
||||
? viewer.indexOf('</CctvFullscreenModal>')
|
||||
: viewer.indexOf('/>', viewer.indexOf('<CctvFullscreenModal')) + 2,
|
||||
);
|
||||
expect(cctvSection).toContain('<CctvFullscreenModal');
|
||||
expect(cctvSection).toContain('url={url}');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Popup explicit props ──────────────────────────────────────────────────
|
||||
|
||||
describe('MaplibreViewer decomposition — popup explicit props', () => {
|
||||
it('SatellitePopup receives sat and onClose props', () => {
|
||||
const popup = readComp('MaplibreViewer/popups/SatellitePopup.tsx');
|
||||
expect(popup).toMatch(/export\s+interface\s+SatellitePopupProps/);
|
||||
expect(popup).toContain('sat: Satellite');
|
||||
expect(popup).toContain('onClose: () => void');
|
||||
});
|
||||
|
||||
it('ShipPopup receives ship, longitude, latitude, onClose props', () => {
|
||||
const popup = readComp('MaplibreViewer/popups/ShipPopup.tsx');
|
||||
expect(popup).toMatch(/export\s+interface\s+ShipPopupProps/);
|
||||
expect(popup).toContain('ship: Ship');
|
||||
expect(popup).toContain('longitude: number');
|
||||
expect(popup).toContain('latitude: number');
|
||||
expect(popup).toContain('onClose: () => void');
|
||||
});
|
||||
|
||||
it('SigintPopup receives data, lat, lng, kiwisdrs, setTrackedSdr, onClose props', () => {
|
||||
const popup = readComp('MaplibreViewer/popups/SigintPopup.tsx');
|
||||
expect(popup).toMatch(/export\s+interface\s+SigintPopupProps/);
|
||||
expect(popup).toContain('data: SigintData');
|
||||
expect(popup).toContain('lat: number');
|
||||
expect(popup).toContain('lng: number');
|
||||
expect(popup).toContain('kiwisdrs: KiwiSDR[]');
|
||||
expect(popup).toContain('setTrackedSdr');
|
||||
expect(popup).toContain('onClose: () => void');
|
||||
});
|
||||
|
||||
it('MilitaryBasePopup receives base, oracleIntel, onClose props', () => {
|
||||
const popup = readComp('MaplibreViewer/popups/MilitaryBasePopup.tsx');
|
||||
expect(popup).toMatch(/export\s+interface\s+MilitaryBasePopupProps/);
|
||||
expect(popup).toContain('base: MilitaryBase');
|
||||
expect(popup).toContain('oracleIntel');
|
||||
expect(popup).toContain('onClose: () => void');
|
||||
});
|
||||
|
||||
it('RegionDossierPanel receives sentinel2, lat, lng, onClose props', () => {
|
||||
const popup = readComp('MaplibreViewer/popups/RegionDossierPanel.tsx');
|
||||
expect(popup).toMatch(/export\s+interface\s+RegionDossierPanelProps/);
|
||||
expect(popup).toContain('sentinel2: Sentinel2Data');
|
||||
expect(popup).toContain('lat: number');
|
||||
expect(popup).toContain('lng: number');
|
||||
expect(popup).toContain('onClose: () => void');
|
||||
});
|
||||
|
||||
it('SigintPopup imports SigintSendForm and MeshtasticChannelFeed from SigintPanels', () => {
|
||||
const popup = readComp('MaplibreViewer/popups/SigintPopup.tsx');
|
||||
expect(popup).toMatch(
|
||||
/import\s*\{[^}]*SigintSendForm[^}]*\}\s*from\s+['"]@\/components\/map\/panels\/SigintPanels['"]/,
|
||||
);
|
||||
expect(popup).toMatch(
|
||||
/import\s*\{[^}]*MeshtasticChannelFeed[^}]*\}\s*from\s+['"]@\/components\/map\/panels\/SigintPanels['"]/,
|
||||
);
|
||||
});
|
||||
|
||||
it('SigintPopup computes nearestSdr internally (not passed from parent)', () => {
|
||||
const popup = readComp('MaplibreViewer/popups/SigintPopup.tsx');
|
||||
expect(popup).toContain('findNearestSdr');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Selection / dismissal behavior ────────────────────────────────────────
|
||||
|
||||
describe('MaplibreViewer decomposition — selection and dismissal', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
|
||||
it('satellite popup calls onEntityClick(null) on close', () => {
|
||||
const satSection = viewer.slice(
|
||||
viewer.indexOf("selectedEntity?.type === 'satellite'"),
|
||||
viewer.indexOf("selectedEntity?.type === 'satellite'") + 500,
|
||||
);
|
||||
expect(satSection).toContain('<SatellitePopup');
|
||||
expect(satSection).toContain('onClose={() => onEntityClick?.(null)}');
|
||||
});
|
||||
|
||||
it('ship popup calls onEntityClick(null) on close', () => {
|
||||
const shipSection = viewer.slice(
|
||||
viewer.indexOf('{/* Ship / carrier click popup */}'),
|
||||
viewer.indexOf('{/* Ship / carrier click popup */}') + 800,
|
||||
);
|
||||
expect(shipSection).toContain('<ShipPopup');
|
||||
expect(shipSection).toContain('onClose={() => onEntityClick?.(null)}');
|
||||
});
|
||||
|
||||
it('sigint popup calls onEntityClick(null) on close', () => {
|
||||
const sigintSection = viewer.slice(
|
||||
viewer.indexOf('{/* SIGINT signal click popup */}'),
|
||||
viewer.indexOf('{/* SIGINT signal click popup */}') + 1200,
|
||||
);
|
||||
expect(sigintSection).toContain('<SigintPopup');
|
||||
expect(sigintSection).toContain('onClose={() => onEntityClick?.(null)}');
|
||||
});
|
||||
|
||||
it('military base popup calls onEntityClick(null) on close', () => {
|
||||
const milSection = viewer.slice(
|
||||
viewer.indexOf("selectedEntity?.type === 'military_base'"),
|
||||
viewer.indexOf("selectedEntity?.type === 'military_base'") + 600,
|
||||
);
|
||||
expect(milSection).toContain('<MilitaryBasePopup');
|
||||
expect(milSection).toContain('onClose={() => onEntityClick?.(null)}');
|
||||
});
|
||||
|
||||
it('region dossier panel calls onEntityClick(null) on close', () => {
|
||||
const rdSection = viewer.slice(
|
||||
viewer.indexOf('{/* SENTINEL-2 IMAGERY'),
|
||||
viewer.indexOf('{/* SENTINEL-2 IMAGERY') + 500,
|
||||
);
|
||||
expect(rdSection).toContain('<RegionDossierPanel');
|
||||
expect(rdSection).toContain('onClose={() => onEntityClick(null)}');
|
||||
});
|
||||
|
||||
it('CCTV fullscreen modal calls onEntityClick(null) on close', () => {
|
||||
const cctvSection = viewer.slice(
|
||||
viewer.indexOf("selectedEntity?.type === 'cctv'"),
|
||||
viewer.indexOf("selectedEntity?.type === 'cctv'") + 1600,
|
||||
);
|
||||
expect(cctvSection).toContain('<CctvFullscreenModal');
|
||||
expect(cctvSection).toContain('onClose={() => onEntityClick(null)}');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MaplibreViewer retains core responsibilities ──────────────────────────
|
||||
|
||||
describe('MaplibreViewer decomposition — retained core', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
|
||||
it('MaplibreViewer retains <Map> component', () => {
|
||||
expect(viewer).toContain('<Map');
|
||||
expect(viewer).toContain('</Map>');
|
||||
});
|
||||
|
||||
it('MaplibreViewer retains mapRef', () => {
|
||||
expect(viewer).toMatch(/mapRef\s*=\s*useRef/);
|
||||
});
|
||||
|
||||
it('MaplibreViewer retains mapInitRef', () => {
|
||||
expect(viewer).toMatch(/mapInitRef\s*=\s*useRef/);
|
||||
});
|
||||
|
||||
it('MaplibreViewer retains initializeMap', () => {
|
||||
expect(viewer).toContain('initializeMap');
|
||||
});
|
||||
|
||||
it('MaplibreViewer retains useImperativeSource calls', () => {
|
||||
expect(viewer).toContain('useImperativeSource');
|
||||
});
|
||||
|
||||
it('MaplibreViewer retains Source and Layer declarations', () => {
|
||||
expect(viewer).toContain('<Source');
|
||||
expect(viewer).toContain('<Layer');
|
||||
});
|
||||
|
||||
it('MaplibreViewer retains useViewportBounds', () => {
|
||||
expect(viewer).toContain('useViewportBounds');
|
||||
});
|
||||
|
||||
it('MaplibreViewer retains activeInteractiveLayerIds', () => {
|
||||
expect(viewer).toContain('activeInteractiveLayerIds');
|
||||
});
|
||||
|
||||
it('MaplibreViewer retains worker hooks', () => {
|
||||
expect(viewer).toContain('useDynamicMapLayersWorker');
|
||||
expect(viewer).toContain('useStaticMapLayersWorker');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── No keyed subscription regression ──────────────────────────────────────
|
||||
|
||||
describe('MaplibreViewer decomposition — no keyed subscription regression', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
|
||||
it('MaplibreViewer uses useDataKeys (keyed subscription model)', () => {
|
||||
expect(viewer).toContain('useDataKeys');
|
||||
});
|
||||
|
||||
it('MaplibreViewer does NOT use useDataSnapshot', () => {
|
||||
expect(viewer).not.toContain('useDataSnapshot');
|
||||
});
|
||||
|
||||
it('MaplibreViewer imports useDataKeys from @/hooks/useDataStore', () => {
|
||||
expect(viewer).toMatch(
|
||||
/import\s*\{[^}]*useDataKeys[^}]*\}\s*from\s+['"]@\/hooks\/useDataStore['"]/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── No mega-hook extraction ───────────────────────────────────────────────
|
||||
|
||||
describe('MaplibreViewer decomposition — no mega-hook', () => {
|
||||
it('no useMapController hook exists', () => {
|
||||
const viewerDir = path.join(COMP_DIR, 'MaplibreViewer');
|
||||
const files = fs.readdirSync(viewerDir, { recursive: true }) as string[];
|
||||
const hookFiles = files.filter(
|
||||
(f: string) => f.includes('useMapController') || f.includes('use-map-controller'),
|
||||
);
|
||||
expect(hookFiles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('MaplibreViewer does not import useMapController', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
expect(viewer).not.toContain('useMapController');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Popup components use Popup from react-map-gl ──────────────────────────
|
||||
|
||||
describe('MaplibreViewer decomposition — popup components own their Popup wrapper', () => {
|
||||
const popupFiles = [
|
||||
'MaplibreViewer/popups/SatellitePopup.tsx',
|
||||
'MaplibreViewer/popups/ShipPopup.tsx',
|
||||
'MaplibreViewer/popups/SigintPopup.tsx',
|
||||
'MaplibreViewer/popups/MilitaryBasePopup.tsx',
|
||||
];
|
||||
|
||||
for (const file of popupFiles) {
|
||||
const name = path.basename(file, '.tsx');
|
||||
it(`${name} imports Popup from react-map-gl/maplibre`, () => {
|
||||
const content = readComp(file);
|
||||
expect(content).toMatch(
|
||||
/import\s*\{[^}]*Popup[^}]*\}\s*from\s+['"]react-map-gl\/maplibre['"]/,
|
||||
);
|
||||
});
|
||||
|
||||
it(`${name} renders a <Popup> component`, () => {
|
||||
const content = readComp(file);
|
||||
expect(content).toContain('<Popup');
|
||||
});
|
||||
}
|
||||
|
||||
it('RegionDossierPanel renders a fixed overlay (not a map Popup)', () => {
|
||||
const content = readComp('MaplibreViewer/popups/RegionDossierPanel.tsx');
|
||||
expect(content).not.toContain('<Popup');
|
||||
expect(content).toContain("position: 'fixed'");
|
||||
});
|
||||
|
||||
it('CctvFullscreenModal renders a fixed overlay (not a map Popup)', () => {
|
||||
const content = readComp('MaplibreViewer/CctvFullscreenModal.tsx');
|
||||
expect(content).not.toContain('<Popup');
|
||||
expect(content).toContain("position: 'fixed'");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Data lookups stay in MaplibreViewer ────────────────────────────────────
|
||||
|
||||
describe('MaplibreViewer decomposition — data lookups in parent', () => {
|
||||
it('satellite lookup stays in MaplibreViewer', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
expect(viewer).toContain("data?.satellites?.find");
|
||||
});
|
||||
|
||||
it('ship lookup stays in MaplibreViewer', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
expect(viewer).toContain("data?.ships?.find");
|
||||
});
|
||||
|
||||
it('sigint lookup stays in MaplibreViewer', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
expect(viewer).toContain("data?.sigint?.find");
|
||||
});
|
||||
|
||||
it('military_bases lookup stays in MaplibreViewer', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
expect(viewer).toContain("data?.military_bases?.find");
|
||||
});
|
||||
|
||||
it('popup components do NOT access data store directly', () => {
|
||||
const popupFiles = [
|
||||
'MaplibreViewer/popups/SatellitePopup.tsx',
|
||||
'MaplibreViewer/popups/ShipPopup.tsx',
|
||||
'MaplibreViewer/popups/SigintPopup.tsx',
|
||||
'MaplibreViewer/popups/MilitaryBasePopup.tsx',
|
||||
'MaplibreViewer/popups/RegionDossierPanel.tsx',
|
||||
];
|
||||
for (const file of popupFiles) {
|
||||
const content = readComp(file);
|
||||
expect(content).not.toContain('useDataKeys');
|
||||
expect(content).not.toContain('useDataSnapshot');
|
||||
expect(content).not.toContain('useDataStore');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
return fs.readFileSync(path.resolve(here, relativePath), 'utf-8');
|
||||
}
|
||||
|
||||
describe('Sprint 6 DM compatibility sunset policy', () => {
|
||||
it('keeps receive-side MeshChat request parsing off ambient legacy agent-id lookup', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
|
||||
expect(controller).toMatch(
|
||||
/fetchDmPublicKey\(\s*API_BASE,\s*m\.sender_id,\s*senderContact\?\.invitePinnedPrekeyLookupHandle/s,
|
||||
);
|
||||
expect(controller).not.toMatch(
|
||||
/fetchDmPublicKey\(\s*API_BASE,\s*m\.sender_id,[\s\S]{0,200}allowLegacyAgentId:\s*true/s,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps MessagesView receive-side contact parsing off ambient legacy agent-id lookup', () => {
|
||||
const messagesView = readSource('../../components/InfonetTerminal/MessagesView.tsx');
|
||||
|
||||
expect(messagesView).toMatch(
|
||||
/fetchDmPublicKey\(\s*API_BASE,\s*senderId,\s*existingContact\?\.invitePinnedPrekeyLookupHandle/s,
|
||||
);
|
||||
expect(messagesView).not.toMatch(
|
||||
/fetchDmPublicKey\(\s*API_BASE,\s*senderId,[\s\S]{0,200}allowLegacyAgentId:\s*true/s,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps MeshTerminal legacy lookup limited to explicit migration commands', () => {
|
||||
const terminal = readSource('../../components/MeshTerminal.tsx');
|
||||
|
||||
expect(terminal).not.toMatch(
|
||||
/fetchDmPublicKey\(\s*API,\s*message\.sender_id,[\s\S]{0,200}allowLegacyAgentId:/s,
|
||||
);
|
||||
expect(terminal).not.toMatch(
|
||||
/fetchDmPublicKey\(\s*API,\s*m\.sender_id,[\s\S]{0,200}allowLegacyAgentId:/s,
|
||||
);
|
||||
|
||||
const legacyLookupMatches = terminal.match(/allowLegacyAgentId:\s*true/g) || [];
|
||||
expect(legacyLookupMatches).toHaveLength(1);
|
||||
expect(terminal).toContain("only for legacy migration");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function makeStorage() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void values.set(key, value),
|
||||
removeItem: (key: string) => void values.delete(key),
|
||||
clear: () => void values.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('dmPollScheduler', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('jitteredPollDelay', () => {
|
||||
it('returns a value within the default jitter band', async () => {
|
||||
const { jitteredPollDelay } = await import('@/lib/dmPollScheduler');
|
||||
const base = 12_000;
|
||||
// r=0 → factor=0.8 → 9600; r=1 → factor=1.4 → 16800
|
||||
expect(jitteredPollDelay(base, { profile: 'default', random: 0 })).toBe(9600);
|
||||
expect(jitteredPollDelay(base, { profile: 'default', random: 1 })).toBe(16800);
|
||||
});
|
||||
|
||||
it('high-privacy band is wider than default', async () => {
|
||||
const { jitteredPollDelay } = await import('@/lib/dmPollScheduler');
|
||||
const base = 12_000;
|
||||
const defaultMin = jitteredPollDelay(base, { profile: 'default', random: 0 });
|
||||
const defaultMax = jitteredPollDelay(base, { profile: 'default', random: 1 });
|
||||
const highMin = jitteredPollDelay(base, { profile: 'high', random: 0 });
|
||||
const highMax = jitteredPollDelay(base, { profile: 'high', random: 1 });
|
||||
|
||||
const defaultRange = defaultMax - defaultMin;
|
||||
const highRange = highMax - highMin;
|
||||
expect(highRange).toBeGreaterThan(defaultRange);
|
||||
});
|
||||
|
||||
it('never returns the exact base interval across random inputs', async () => {
|
||||
const { jitteredPollDelay } = await import('@/lib/dmPollScheduler');
|
||||
const base = 12_000;
|
||||
const samples = Array.from({ length: 100 }, (_, i) =>
|
||||
jitteredPollDelay(base, { profile: 'default', random: i / 100 }),
|
||||
);
|
||||
// At most one value could accidentally equal base; the set should be diverse
|
||||
const unique = new Set(samples);
|
||||
expect(unique.size).toBeGreaterThan(50);
|
||||
// The exact base value corresponds to r ≈ 0.333...; verify it's the only one
|
||||
const exactBaseCount = samples.filter((v) => v === base).length;
|
||||
expect(exactBaseCount).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('reads privacy profile from browser storage when no override', async () => {
|
||||
sessionStorage.setItem('sb_privacy_profile', 'high');
|
||||
const { jitteredPollDelay } = await import('@/lib/dmPollScheduler');
|
||||
const base = 10_000;
|
||||
// r=0 with high profile → factor=0.5 → 5000
|
||||
expect(jitteredPollDelay(base, { random: 0 })).toBe(5000);
|
||||
});
|
||||
|
||||
it('returns positive value for any base and profile', async () => {
|
||||
const { jitteredPollDelay } = await import('@/lib/dmPollScheduler');
|
||||
for (const profile of ['default', 'high', 'unknown']) {
|
||||
for (const r of [0, 0.25, 0.5, 0.75, 1]) {
|
||||
const delay = jitteredPollDelay(15_000, { profile, random: r });
|
||||
expect(delay).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('catchUpDelay', () => {
|
||||
it('returns a value within the default catch-up band', async () => {
|
||||
const { catchUpDelay } = await import('@/lib/dmPollScheduler');
|
||||
// default: min=2000, max=5000
|
||||
expect(catchUpDelay({ profile: 'default', random: 0 })).toBe(2000);
|
||||
expect(catchUpDelay({ profile: 'default', random: 1 })).toBe(5000);
|
||||
});
|
||||
|
||||
it('high-privacy catch-up delay is longer than default', async () => {
|
||||
const { catchUpDelay } = await import('@/lib/dmPollScheduler');
|
||||
const defaultMid = catchUpDelay({ profile: 'default', random: 0.5 });
|
||||
const highMid = catchUpDelay({ profile: 'high', random: 0.5 });
|
||||
expect(highMid).toBeGreaterThan(defaultMid);
|
||||
});
|
||||
|
||||
it('catch-up delay is always shorter than normal poll delay', async () => {
|
||||
const { jitteredPollDelay, catchUpDelay } = await import('@/lib/dmPollScheduler');
|
||||
// Worst-case catch-up (r=1, high) vs best-case normal poll (r=0, default, base=12000)
|
||||
const maxCatchUp = catchUpDelay({ profile: 'high', random: 1 });
|
||||
const minNormal = jitteredPollDelay(12_000, { profile: 'default', random: 0 });
|
||||
expect(maxCatchUp).toBeLessThan(minNormal);
|
||||
});
|
||||
|
||||
it('catch-up delay is never zero', async () => {
|
||||
const { catchUpDelay } = await import('@/lib/dmPollScheduler');
|
||||
for (const profile of ['default', 'high']) {
|
||||
const delay = catchUpDelay({ profile, random: 0 });
|
||||
expect(delay).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MAX_CATCHUP_POLLS', () => {
|
||||
it('is a small positive integer bounding catch-up bursts', async () => {
|
||||
const { MAX_CATCHUP_POLLS } = await import('@/lib/dmPollScheduler');
|
||||
expect(MAX_CATCHUP_POLLS).toBeGreaterThanOrEqual(1);
|
||||
expect(MAX_CATCHUP_POLLS).toBeLessThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyTick', () => {
|
||||
it('catch-up tick skips count refresh', async () => {
|
||||
const { classifyTick } = await import('@/lib/dmPollScheduler');
|
||||
const result = classifyTick(true, 3, 12_000, { profile: 'default', random: 0.5 });
|
||||
expect(result.refreshCount).toBe(false);
|
||||
expect(result.newBudget).toBe(2);
|
||||
});
|
||||
|
||||
it('normal tick includes count refresh', async () => {
|
||||
const { classifyTick } = await import('@/lib/dmPollScheduler');
|
||||
const result = classifyTick(false, 3, 12_000, { profile: 'default', random: 0.5 });
|
||||
expect(result.refreshCount).toBe(true);
|
||||
});
|
||||
|
||||
it('budget exhaustion falls back to normal with count', async () => {
|
||||
const { classifyTick } = await import('@/lib/dmPollScheduler');
|
||||
// has_more=true but budget=0 → normal tick
|
||||
const result = classifyTick(true, 0, 12_000, { profile: 'default', random: 0.5 });
|
||||
expect(result.refreshCount).toBe(true);
|
||||
});
|
||||
|
||||
it('budget resets after fallback to normal', async () => {
|
||||
const { classifyTick, MAX_CATCHUP_POLLS } = await import('@/lib/dmPollScheduler');
|
||||
const result = classifyTick(false, 1, 12_000, { profile: 'default', random: 0.5 });
|
||||
expect(result.newBudget).toBe(MAX_CATCHUP_POLLS);
|
||||
});
|
||||
|
||||
it('catch-up delay is used during catch-up ticks', async () => {
|
||||
const { classifyTick, catchUpDelay } = await import('@/lib/dmPollScheduler');
|
||||
const opts = { profile: 'default' as const, random: 0.5 };
|
||||
const result = classifyTick(true, 2, 12_000, opts);
|
||||
expect(result.delay).toBe(catchUpDelay(opts));
|
||||
});
|
||||
|
||||
it('normal delay is used during normal ticks', async () => {
|
||||
const { classifyTick, jitteredPollDelay } = await import('@/lib/dmPollScheduler');
|
||||
const opts = { profile: 'default' as const, random: 0.5 };
|
||||
const result = classifyTick(false, 3, 12_000, opts);
|
||||
expect(result.delay).toBe(jitteredPollDelay(12_000, opts));
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduling contract', () => {
|
||||
it('simulated poll loop uses classifyTick for cadence and count decisions', async () => {
|
||||
const { classifyTick, catchUpDelay, jitteredPollDelay, MAX_CATCHUP_POLLS } =
|
||||
await import('@/lib/dmPollScheduler');
|
||||
|
||||
const ticks: Array<{ delay: number; refreshCount: boolean }> = [];
|
||||
let budget = MAX_CATCHUP_POLLS;
|
||||
const hasMoreSequence = [true, true, true, true, false, false, true, false];
|
||||
const opts = { profile: 'default' as const, random: 0.5 };
|
||||
|
||||
for (const hasMore of hasMoreSequence) {
|
||||
const result = classifyTick(hasMore, budget, 12_000, opts);
|
||||
budget = result.newBudget;
|
||||
ticks.push({ delay: result.delay, refreshCount: result.refreshCount });
|
||||
}
|
||||
|
||||
const catchUpValue = catchUpDelay(opts);
|
||||
const normalValue = jitteredPollDelay(12_000, opts);
|
||||
|
||||
// First MAX_CATCHUP_POLLS catch-up ticks: short delay, no count
|
||||
for (let i = 0; i < MAX_CATCHUP_POLLS; i++) {
|
||||
expect(ticks[i].delay).toBe(catchUpValue);
|
||||
expect(ticks[i].refreshCount).toBe(false);
|
||||
}
|
||||
// 4th has_more exceeds budget → normal with count
|
||||
expect(ticks[MAX_CATCHUP_POLLS].delay).toBe(normalValue);
|
||||
expect(ticks[MAX_CATCHUP_POLLS].refreshCount).toBe(true);
|
||||
// Non-has_more ticks: normal with count
|
||||
expect(ticks[4].delay).toBe(normalValue);
|
||||
expect(ticks[4].refreshCount).toBe(true);
|
||||
expect(ticks[5].delay).toBe(normalValue);
|
||||
expect(ticks[5].refreshCount).toBe(true);
|
||||
});
|
||||
|
||||
it('count is never refreshed during catch-up across a full backlog drain', async () => {
|
||||
const { classifyTick, MAX_CATCHUP_POLLS } = await import('@/lib/dmPollScheduler');
|
||||
|
||||
let budget = MAX_CATCHUP_POLLS;
|
||||
const countRefreshes: boolean[] = [];
|
||||
|
||||
// Simulate: has_more for exactly budget ticks, then two normal ticks
|
||||
const hasMoreSequence = [
|
||||
...Array(MAX_CATCHUP_POLLS).fill(true),
|
||||
false,
|
||||
false,
|
||||
];
|
||||
for (const hasMore of hasMoreSequence) {
|
||||
const result = classifyTick(hasMore, budget, 12_000, { profile: 'default', random: 0.5 });
|
||||
budget = result.newBudget;
|
||||
countRefreshes.push(result.refreshCount);
|
||||
}
|
||||
|
||||
// Catch-up ticks should not refresh count
|
||||
for (let i = 0; i < MAX_CATCHUP_POLLS; i++) {
|
||||
expect(countRefreshes[i]).toBe(false);
|
||||
}
|
||||
// Normal ticks after catch-up do refresh count
|
||||
expect(countRefreshes[MAX_CATCHUP_POLLS]).toBe(true);
|
||||
expect(countRefreshes[MAX_CATCHUP_POLLS + 1]).toBe(true);
|
||||
});
|
||||
|
||||
it('no fixed cadence is reintroduced by classifyTick', async () => {
|
||||
const { classifyTick } = await import('@/lib/dmPollScheduler');
|
||||
const delays = new Set<number>();
|
||||
for (let r = 0; r < 20; r++) {
|
||||
const result = classifyTick(false, 3, 12_000, { profile: 'default', random: r / 20 });
|
||||
delays.add(result.delay);
|
||||
}
|
||||
// All 20 random inputs should produce diverse delays, not a fixed value
|
||||
expect(delays.size).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const controlPlaneJson = vi.fn();
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson,
|
||||
}));
|
||||
|
||||
describe('DM selftest client', () => {
|
||||
beforeEach(() => {
|
||||
controlPlaneJson.mockReset();
|
||||
});
|
||||
|
||||
it('runs the local DM selftest without requiring an admin browser session', async () => {
|
||||
controlPlaneJson.mockResolvedValue({ ok: true });
|
||||
|
||||
const { runWormholeDmSelftest } = await import('@/mesh/wormholeIdentityClient');
|
||||
|
||||
await runWormholeDmSelftest('probe');
|
||||
|
||||
expect(controlPlaneJson).toHaveBeenCalledWith(
|
||||
'/api/wormhole/dm/selftest',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
body: JSON.stringify({ message: 'probe' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const controlPlaneJson = vi.fn();
|
||||
const hasLocalControlBridge = vi.fn(() => false);
|
||||
const getGateSessionStreamAccessHeaders = vi.fn();
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/localControlTransport', () => ({
|
||||
hasLocalControlBridge,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/gateSessionStream', () => ({
|
||||
getGateSessionStreamAccessHeaders,
|
||||
}));
|
||||
|
||||
describe('gateAccessProof cache', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
controlPlaneJson.mockReset();
|
||||
hasLocalControlBridge.mockReset();
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
getGateSessionStreamAccessHeaders.mockReset();
|
||||
getGateSessionStreamAccessHeaders.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it('caches browser/web gate proofs just under the backend validity window', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T22:40:00.000Z'));
|
||||
try {
|
||||
controlPlaneJson.mockResolvedValue({
|
||||
node_id: '!sb_gate',
|
||||
ts: 1712345678,
|
||||
proof: 'proof-a',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateAccessProof');
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance')).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
await expect(mod.buildGateAccessHeaders('finance')).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(52_001);
|
||||
|
||||
await mod.buildGateAccessHeaders('finance');
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a shorter proof cache window on native runtimes', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T22:40:00.000Z'));
|
||||
try {
|
||||
hasLocalControlBridge.mockReturnValue(true);
|
||||
controlPlaneJson.mockResolvedValue({
|
||||
node_id: '!sb_gate',
|
||||
ts: 1712345678,
|
||||
proof: 'proof-native',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateAccessProof');
|
||||
|
||||
await mod.buildGateAccessHeaders('finance');
|
||||
vi.advanceTimersByTime(35_001);
|
||||
await mod.buildGateAccessHeaders('finance');
|
||||
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('coalesces concurrent proof requests for the same gate into one control-plane call', async () => {
|
||||
let release: ((value: { node_id: string; ts: number; proof: string }) => void) | null = null;
|
||||
controlPlaneJson.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve as typeof release;
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateAccessProof');
|
||||
|
||||
const first = mod.buildGateAccessHeaders('finance');
|
||||
const second = mod.buildGateAccessHeaders('finance');
|
||||
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(1);
|
||||
|
||||
release?.({
|
||||
node_id: '!sb_gate',
|
||||
ts: 1712345678,
|
||||
proof: 'proof-a',
|
||||
});
|
||||
|
||||
await expect(first).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
await expect(second).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses stream bootstrap access headers before falling back to the gate proof endpoint', async () => {
|
||||
getGateSessionStreamAccessHeaders.mockReturnValue({
|
||||
'X-Wormhole-Node-Id': '!sb_stream',
|
||||
'X-Wormhole-Gate-Proof': 'proof-stream',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateAccessProof');
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance', { mode: 'session_stream' })).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_stream',
|
||||
'X-Wormhole-Gate-Proof': 'proof-stream',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
expect(getGateSessionStreamAccessHeaders).toHaveBeenCalledWith('finance');
|
||||
expect(controlPlaneJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reuses a fresh-enough proof longer for held wait requests than for ordinary reads', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T22:40:00.000Z'));
|
||||
try {
|
||||
const firstTs = Math.floor(Date.now() / 1000);
|
||||
const secondTs = Math.floor((Date.now() + 55_000) / 1000);
|
||||
controlPlaneJson
|
||||
.mockResolvedValueOnce({
|
||||
node_id: '!sb_gate',
|
||||
ts: firstTs,
|
||||
proof: 'proof-a',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
node_id: '!sb_gate',
|
||||
ts: secondTs,
|
||||
proof: 'proof-b',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateAccessProof');
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance')).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': String(firstTs),
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(55_000);
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance', { mode: 'wait' })).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': String(firstTs),
|
||||
});
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance')).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-b',
|
||||
'X-Wormhole-Gate-Ts': String(secondTs),
|
||||
});
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses a fresh-enough proof longer for session-stream refreshes than for ordinary reads', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T22:40:00.000Z'));
|
||||
try {
|
||||
const firstTs = Math.floor(Date.now() / 1000);
|
||||
const secondTs = Math.floor((Date.now() + 55_000) / 1000);
|
||||
controlPlaneJson
|
||||
.mockResolvedValueOnce({
|
||||
node_id: '!sb_gate',
|
||||
ts: firstTs,
|
||||
proof: 'proof-a',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
node_id: '!sb_gate',
|
||||
ts: secondTs,
|
||||
proof: 'proof-b',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateAccessProof');
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance')).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': String(firstTs),
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(55_000);
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance', { mode: 'session_stream' })).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': String(firstTs),
|
||||
});
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance')).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-b',
|
||||
'X-Wormhole-Gate-Ts': String(secondTs),
|
||||
});
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hasLocalControlBridge = vi.fn(() => false);
|
||||
|
||||
vi.mock('@/lib/localControlTransport', () => ({
|
||||
hasLocalControlBridge,
|
||||
}));
|
||||
|
||||
describe('gateCatalogSnapshot cache', () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
fetchMock.mockReset();
|
||||
hasLocalControlBridge.mockReset();
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
it('coarsens browser/web gate catalog reads through a short shared cache window', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T23:10:00.000Z'));
|
||||
try {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gates: [{ gate_id: 'infonet', display_name: 'Infonet Commons' }],
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateCatalogSnapshot');
|
||||
|
||||
await expect(mod.fetchGateCatalogSnapshot()).resolves.toEqual([
|
||||
{ gate_id: 'infonet', display_name: 'Infonet Commons' },
|
||||
]);
|
||||
await expect(mod.fetchGateCatalogSnapshot()).resolves.toEqual([
|
||||
{ gate_id: 'infonet', display_name: 'Infonet Commons' },
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(18_001);
|
||||
|
||||
await mod.fetchGateCatalogSnapshot();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a shorter cache window for native gate catalog/detail snapshots', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T23:10:00.000Z'));
|
||||
try {
|
||||
hasLocalControlBridge.mockReturnValue(true);
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gates: [{ gate_id: 'finance', display_name: 'Finance' }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gates: [{ gate_id: 'finance', display_name: 'Finance' }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gate_id: 'finance',
|
||||
display_name: 'Finance',
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gate_id: 'finance',
|
||||
display_name: 'Finance',
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateCatalogSnapshot');
|
||||
|
||||
await mod.fetchGateCatalogSnapshot();
|
||||
vi.advanceTimersByTime(6_001);
|
||||
await mod.fetchGateCatalogSnapshot();
|
||||
|
||||
await mod.fetchGateDetailSnapshot('finance');
|
||||
vi.advanceTimersByTime(5_001);
|
||||
await mod.fetchGateDetailSnapshot('finance');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('invalidates cached gate detail snapshots explicitly', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gate_id: 'infonet',
|
||||
display_name: 'Infonet Commons',
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gate_id: 'infonet',
|
||||
display_name: 'Infonet Commons v2',
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateCatalogSnapshot');
|
||||
|
||||
await expect(mod.fetchGateDetailSnapshot('infonet')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
gate_id: 'infonet',
|
||||
display_name: 'Infonet Commons',
|
||||
}),
|
||||
);
|
||||
mod.invalidateGateDetailSnapshot('infonet');
|
||||
await expect(mod.fetchGateDetailSnapshot('infonet')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
gate_id: 'infonet',
|
||||
display_name: 'Infonet Commons v2',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,737 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
controlPlaneJson: vi.fn(),
|
||||
approveGateCompatFallback: vi.fn(),
|
||||
decryptWormholeGateMessages: vi.fn(),
|
||||
fetchWormholeGateKeyStatus: vi.fn(),
|
||||
hasGateCompatFallbackApproval: vi.fn(() => false),
|
||||
postWormholeGateMessage: vi.fn(),
|
||||
prepareWormholeInteractiveLane: vi.fn(async () => ({
|
||||
ready: true,
|
||||
settingsEnabled: true,
|
||||
transportTier: 'private_transitional',
|
||||
identity: null,
|
||||
})),
|
||||
revokeGateCompatFallback: vi.fn(),
|
||||
syncBrowserWormholeGateState: vi.fn(async () => true),
|
||||
getGateSessionStreamStatus: vi.fn(() => ({
|
||||
enabled: false,
|
||||
phase: 'idle',
|
||||
transport: 'sse',
|
||||
sessionId: '',
|
||||
subscriptions: [],
|
||||
heartbeatS: 0,
|
||||
batchMs: 0,
|
||||
lastEventType: '',
|
||||
lastEventAt: 0,
|
||||
detail: '',
|
||||
})),
|
||||
retainGateSessionStreamGate: vi.fn(() => vi.fn()),
|
||||
subscribeGateSessionStreamEvents: vi.fn(() => vi.fn()),
|
||||
subscribeGateSessionStreamStatus: vi.fn((listener: (status: unknown) => void) => {
|
||||
listener(mocks.getGateSessionStreamStatus());
|
||||
return vi.fn();
|
||||
}),
|
||||
getGateSessionStreamAccessHeaders: vi.fn(() => undefined),
|
||||
getGateSessionStreamKeyStatus: vi.fn(() => null),
|
||||
invalidateGateSessionStreamGateContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
API_BASE: 'http://test.local',
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson: mocks.controlPlaneJson,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
nextSequence: vi.fn(() => 1),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
approveGateCompatFallback: mocks.approveGateCompatFallback,
|
||||
decryptWormholeGateMessages: mocks.decryptWormholeGateMessages,
|
||||
fetchWormholeGateKeyStatus: mocks.fetchWormholeGateKeyStatus,
|
||||
hasGateCompatFallbackApproval: mocks.hasGateCompatFallbackApproval,
|
||||
postWormholeGateMessage: mocks.postWormholeGateMessage,
|
||||
prepareWormholeInteractiveLane: mocks.prepareWormholeInteractiveLane,
|
||||
revokeGateCompatFallback: mocks.revokeGateCompatFallback,
|
||||
signMeshEvent: vi.fn(),
|
||||
syncBrowserWormholeGateState: mocks.syncBrowserWormholeGateState,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/gateEnvelope', () => ({
|
||||
gateEnvelopeDisplayText: vi.fn(() => 'sealed'),
|
||||
gateEnvelopeState: vi.fn(() => 'sealed'),
|
||||
isEncryptedGateEnvelope: vi.fn((message: { ciphertext?: string }) => Boolean(message?.ciphertext)),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshSchema', () => ({
|
||||
validateEventPayload: vi.fn(() => ({ ok: true })),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useGateSSE', () => ({
|
||||
useGateSSE: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/gateSessionStream', () => ({
|
||||
getGateSessionStreamAccessHeaders: mocks.getGateSessionStreamAccessHeaders,
|
||||
getGateSessionStreamKeyStatus: mocks.getGateSessionStreamKeyStatus,
|
||||
getGateSessionStreamStatus: mocks.getGateSessionStreamStatus,
|
||||
invalidateGateSessionStreamGateContext: mocks.invalidateGateSessionStreamGateContext,
|
||||
retainGateSessionStreamGate: mocks.retainGateSessionStreamGate,
|
||||
subscribeGateSessionStreamEvents: mocks.subscribeGateSessionStreamEvents,
|
||||
subscribeGateSessionStreamStatus: mocks.subscribeGateSessionStreamStatus,
|
||||
}));
|
||||
|
||||
describe('GateView compat-decrypt UX', () => {
|
||||
let streamStatusListeners: Array<(status: unknown) => void> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
streamStatusListeners = [];
|
||||
mocks.controlPlaneJson.mockReset();
|
||||
mocks.approveGateCompatFallback.mockReset();
|
||||
mocks.decryptWormholeGateMessages.mockReset();
|
||||
mocks.fetchWormholeGateKeyStatus.mockReset();
|
||||
mocks.hasGateCompatFallbackApproval.mockReset();
|
||||
mocks.hasGateCompatFallbackApproval.mockReturnValue(false);
|
||||
mocks.postWormholeGateMessage.mockReset();
|
||||
mocks.prepareWormholeInteractiveLane.mockReset();
|
||||
mocks.prepareWormholeInteractiveLane.mockResolvedValue({
|
||||
ready: true,
|
||||
settingsEnabled: true,
|
||||
transportTier: 'private_transitional',
|
||||
identity: null,
|
||||
});
|
||||
mocks.revokeGateCompatFallback.mockReset();
|
||||
mocks.syncBrowserWormholeGateState.mockReset();
|
||||
mocks.getGateSessionStreamStatus.mockReset();
|
||||
mocks.retainGateSessionStreamGate.mockReset();
|
||||
mocks.subscribeGateSessionStreamEvents.mockReset();
|
||||
mocks.subscribeGateSessionStreamStatus.mockReset();
|
||||
mocks.getGateSessionStreamAccessHeaders.mockReset();
|
||||
mocks.getGateSessionStreamAccessHeaders.mockReturnValue(undefined);
|
||||
mocks.getGateSessionStreamKeyStatus.mockReset();
|
||||
mocks.getGateSessionStreamKeyStatus.mockReturnValue(null);
|
||||
mocks.invalidateGateSessionStreamGateContext.mockReset();
|
||||
mocks.syncBrowserWormholeGateState.mockResolvedValue(true);
|
||||
mocks.getGateSessionStreamStatus.mockReturnValue({
|
||||
enabled: false,
|
||||
phase: 'idle',
|
||||
transport: 'sse',
|
||||
sessionId: '',
|
||||
subscriptions: [],
|
||||
heartbeatS: 0,
|
||||
batchMs: 0,
|
||||
lastEventType: '',
|
||||
lastEventAt: 0,
|
||||
detail: '',
|
||||
});
|
||||
mocks.retainGateSessionStreamGate.mockReturnValue(vi.fn());
|
||||
mocks.subscribeGateSessionStreamEvents.mockReturnValue(vi.fn());
|
||||
mocks.subscribeGateSessionStreamStatus.mockImplementation((listener: (status: unknown) => void) => {
|
||||
streamStatusListeners.push(listener);
|
||||
listener(mocks.getGateSessionStreamStatus());
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
mocks.fetchWormholeGateKeyStatus.mockResolvedValue({
|
||||
ok: true,
|
||||
has_local_access: true,
|
||||
identity_scope: 'gate',
|
||||
});
|
||||
mocks.controlPlaneJson.mockResolvedValue({
|
||||
node_id: '!sb_local',
|
||||
proof: 'proof-token',
|
||||
ts: 1712345678,
|
||||
});
|
||||
mocks.decryptWormholeGateMessages.mockResolvedValue({
|
||||
ok: true,
|
||||
results: [
|
||||
{
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
plaintext: 'sealed',
|
||||
identity_scope: 'browser_privacy_core',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: string | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/api/mesh/infonet/messages')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
timestamp: 1712345678,
|
||||
payload: {
|
||||
gate: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
nonce: 'nonce-1',
|
||||
sender_ref: 'sender-ref-1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'gate-envelope-1',
|
||||
envelope_hash: 'hash-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes('/api/mesh/reputation/batch')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ reputations: {} }),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch url: ${url}`);
|
||||
}),
|
||||
);
|
||||
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const emitStreamStatus = (status: {
|
||||
enabled: boolean;
|
||||
phase: 'idle' | 'connecting' | 'open' | 'closed' | 'disabled' | 'error';
|
||||
transport: 'sse';
|
||||
sessionId: string;
|
||||
subscriptions: string[];
|
||||
heartbeatS: number;
|
||||
batchMs: number;
|
||||
lastEventType: string;
|
||||
lastEventAt: number;
|
||||
detail: string;
|
||||
}) => {
|
||||
mocks.getGateSessionStreamStatus.mockReturnValue(status);
|
||||
streamStatusListeners.forEach((listener) => listener(status));
|
||||
};
|
||||
|
||||
it('shows a clear room error when browser-local gate runtime is required', async () => {
|
||||
mocks.decryptWormholeGateMessages.mockRejectedValue(
|
||||
new Error('gate_local_runtime_required:browser_gate_state_resync_required:infonet'),
|
||||
);
|
||||
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Local infonet state needs a resync on this device. Use native desktop or resync local gate state.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'ENABLE FOR ROOM' })).not.toBeInTheDocument();
|
||||
expect(mocks.syncBrowserWormholeGateState).toHaveBeenCalledWith('infonet');
|
||||
expect(mocks.decryptWormholeGateMessages).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
gate_id: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
envelope_hash: 'hash-1',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps recovery-only decrypt failures out of the red room-error path', async () => {
|
||||
mocks.decryptWormholeGateMessages.mockResolvedValue({
|
||||
ok: true,
|
||||
results: [
|
||||
{
|
||||
ok: false,
|
||||
detail: 'gate_backend_decrypt_recovery_only',
|
||||
gate_id: 'infonet',
|
||||
compat_requested: true,
|
||||
compat_effective: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mocks.decryptWormholeGateMessages).toHaveBeenCalled());
|
||||
expect(screen.queryByText('COMPAT MODE')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'Service-side gate decrypt is disabled on this runtime. Use native desktop or an explicit recovery path.',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('sealed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a friendly room message instead of a raw transport-tier gate post failure', async () => {
|
||||
mocks.postWormholeGateMessage.mockRejectedValue(new Error('transport tier insufficient'));
|
||||
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('sealed')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(mocks.prepareWormholeInteractiveLane).toHaveBeenCalledWith({
|
||||
minimumTransportTier: 'private_control_only',
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Post into this gate...'), {
|
||||
target: { value: 'hello' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /post/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'The obfuscated lane is still warming up in the background. Stay in the room and posting should unlock shortly.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a friendly room message instead of a raw gate-envelope post failure', async () => {
|
||||
mocks.postWormholeGateMessage.mockRejectedValue(new Error('gate_envelope_required'));
|
||||
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('sealed')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(mocks.prepareWormholeInteractiveLane).toHaveBeenCalledWith({
|
||||
minimumTransportTier: 'private_control_only',
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Post into this gate...'), {
|
||||
target: { value: 'hello' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /post/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('Local gate sealing is warming up. Your draft is still here.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does one initial gate fetch and then switches to wait-for-change reads', async () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/api/mesh/infonet/messages/wait?')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gate: 'infonet',
|
||||
changed: false,
|
||||
cursor: 1,
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
timestamp: 1712345678,
|
||||
payload: {
|
||||
gate: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
nonce: 'nonce-1',
|
||||
sender_ref: 'sender-ref-1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'gate-envelope-1',
|
||||
envelope_hash: 'hash-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes('/api/mesh/infonet/messages?gate=')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
cursor: 1,
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
timestamp: 1712345678,
|
||||
payload: {
|
||||
gate: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
nonce: 'nonce-1',
|
||||
sender_ref: 'sender-ref-1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'gate-envelope-1',
|
||||
envelope_hash: 'hash-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes('/api/mesh/reputation/batch')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ reputations: {} }),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch url: ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const gateSnapshotModule = await import('@/mesh/gateMessageSnapshot');
|
||||
const fetchSnapshotSpy = vi.spyOn(gateSnapshotModule, 'fetchGateMessageSnapshotState');
|
||||
const waitSnapshotSpy = vi.spyOn(gateSnapshotModule, 'waitForGateMessageSnapshot');
|
||||
gateSnapshotModule.invalidateGateMessageSnapshot('infonet');
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('sealed')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([input]) =>
|
||||
String(input).includes('/api/mesh/infonet/messages/wait?gate=infonet&after=1'),
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
expect(fetchSnapshotSpy).toHaveBeenCalledWith('infonet', 40, expect.any(Object));
|
||||
expect(waitSnapshotSpy).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
1,
|
||||
40,
|
||||
expect.objectContaining({ timeoutMs: expect.any(Number), signal: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses stream-driven room updates as the steady-state path when the gate session stream is open', async () => {
|
||||
const streamEventListeners: Array<(event: { event: string; data: unknown }) => void> = [];
|
||||
mocks.getGateSessionStreamAccessHeaders.mockReturnValue({
|
||||
'X-Wormhole-Node-Id': '!sb_stream',
|
||||
'X-Wormhole-Gate-Proof': 'proof-stream',
|
||||
'X-Wormhole-Gate-Ts': '1712360000',
|
||||
});
|
||||
emitStreamStatus({
|
||||
enabled: true,
|
||||
phase: 'open',
|
||||
transport: 'sse',
|
||||
sessionId: 'sess-1',
|
||||
subscriptions: ['infonet'],
|
||||
heartbeatS: 20,
|
||||
batchMs: 1500,
|
||||
lastEventType: 'hello',
|
||||
lastEventAt: 1712360000,
|
||||
detail: '',
|
||||
});
|
||||
mocks.subscribeGateSessionStreamStatus.mockImplementation((listener: (status: unknown) => void) => {
|
||||
streamStatusListeners.push(listener);
|
||||
listener(mocks.getGateSessionStreamStatus());
|
||||
return vi.fn();
|
||||
});
|
||||
mocks.subscribeGateSessionStreamEvents.mockImplementation((listener: (event: { event: string; data: unknown }) => void) => {
|
||||
streamEventListeners.push(listener);
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/api/mesh/infonet/messages?gate=')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
cursor: url.includes('force') ? 2 : 1,
|
||||
messages: [
|
||||
{
|
||||
event_id: url.includes('force') ? 'evt-2' : 'evt-1',
|
||||
timestamp: 1712345678,
|
||||
payload: {
|
||||
gate: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
nonce: 'nonce-1',
|
||||
sender_ref: 'sender-ref-1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'gate-envelope-1',
|
||||
envelope_hash: 'hash-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes('/api/mesh/reputation/batch')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ reputations: {} }),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch url: ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const gateSnapshotModule = await import('@/mesh/gateMessageSnapshot');
|
||||
const fetchSnapshotSpy = vi.spyOn(gateSnapshotModule, 'fetchGateMessageSnapshotState');
|
||||
const waitSnapshotSpy = vi.spyOn(gateSnapshotModule, 'waitForGateMessageSnapshot');
|
||||
gateSnapshotModule.invalidateGateMessageSnapshot('infonet');
|
||||
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('sealed')).toBeInTheDocument();
|
||||
expect(mocks.subscribeGateSessionStreamEvents).toHaveBeenCalled();
|
||||
expect(mocks.fetchWormholeGateKeyStatus).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
expect.objectContaining({ mode: 'session_stream' }),
|
||||
);
|
||||
expect(mocks.controlPlaneJson).not.toHaveBeenCalled();
|
||||
waitSnapshotSpy.mockClear();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(waitSnapshotSpy).not.toHaveBeenCalled();
|
||||
|
||||
streamEventListeners.forEach((listener) =>
|
||||
listener({
|
||||
event: 'gate_update',
|
||||
data: {
|
||||
session_id: 'sess-1',
|
||||
updates: [{ gate_id: 'infonet', cursor: 2 }],
|
||||
ts: 1712360001,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchSnapshotSpy).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
40,
|
||||
expect.objectContaining({ force: true, proofMode: 'session_stream' }),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([input]) =>
|
||||
String(input).includes('/api/mesh/infonet/messages/wait?'),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(mocks.controlPlaneJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to wait-for-change on stream loss and hands control back after reconnect', async () => {
|
||||
const streamEventListeners: Array<(event: { event: string; data: unknown }) => void> = [];
|
||||
emitStreamStatus({
|
||||
enabled: true,
|
||||
phase: 'open',
|
||||
transport: 'sse',
|
||||
sessionId: 'sess-2',
|
||||
subscriptions: ['infonet'],
|
||||
heartbeatS: 20,
|
||||
batchMs: 1500,
|
||||
lastEventType: 'hello',
|
||||
lastEventAt: 1712360100,
|
||||
detail: '',
|
||||
});
|
||||
mocks.subscribeGateSessionStreamEvents.mockImplementation((listener: (event: { event: string; data: unknown }) => void) => {
|
||||
streamEventListeners.push(listener);
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/api/mesh/infonet/messages/wait?')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gate: 'infonet',
|
||||
changed: false,
|
||||
cursor: 1,
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
timestamp: 1712345678,
|
||||
payload: {
|
||||
gate: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
nonce: 'nonce-1',
|
||||
sender_ref: 'sender-ref-1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'gate-envelope-1',
|
||||
envelope_hash: 'hash-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes('/api/mesh/infonet/messages?gate=')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
cursor: 1,
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
timestamp: 1712345678,
|
||||
payload: {
|
||||
gate: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
nonce: 'nonce-1',
|
||||
sender_ref: 'sender-ref-1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'gate-envelope-1',
|
||||
envelope_hash: 'hash-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes('/api/mesh/reputation/batch')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ reputations: {} }),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch url: ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const gateSnapshotModule = await import('@/mesh/gateMessageSnapshot');
|
||||
const fetchSnapshotSpy = vi.spyOn(gateSnapshotModule, 'fetchGateMessageSnapshotState');
|
||||
const waitSnapshotSpy = vi.spyOn(gateSnapshotModule, 'waitForGateMessageSnapshot');
|
||||
gateSnapshotModule.invalidateGateMessageSnapshot('infonet');
|
||||
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('sealed')).toBeInTheDocument();
|
||||
waitSnapshotSpy.mockClear();
|
||||
|
||||
emitStreamStatus({
|
||||
enabled: false,
|
||||
phase: 'closed',
|
||||
transport: 'sse',
|
||||
sessionId: 'sess-2',
|
||||
subscriptions: ['infonet'],
|
||||
heartbeatS: 20,
|
||||
batchMs: 1500,
|
||||
lastEventType: 'heartbeat',
|
||||
lastEventAt: 1712360200,
|
||||
detail: 'gate_session_stream_closed',
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(waitSnapshotSpy).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
1,
|
||||
40,
|
||||
expect.objectContaining({ timeoutMs: expect.any(Number), signal: expect.any(Object) }),
|
||||
),
|
||||
);
|
||||
|
||||
waitSnapshotSpy.mockClear();
|
||||
fetchSnapshotSpy.mockClear();
|
||||
|
||||
emitStreamStatus({
|
||||
enabled: true,
|
||||
phase: 'open',
|
||||
transport: 'sse',
|
||||
sessionId: 'sess-3',
|
||||
subscriptions: ['infonet'],
|
||||
heartbeatS: 20,
|
||||
batchMs: 1500,
|
||||
lastEventType: 'hello',
|
||||
lastEventAt: 1712360300,
|
||||
detail: '',
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(waitSnapshotSpy).not.toHaveBeenCalled();
|
||||
|
||||
streamEventListeners.forEach((listener) =>
|
||||
listener({
|
||||
event: 'gate_update',
|
||||
data: {
|
||||
session_id: 'sess-3',
|
||||
updates: [{ gate_id: 'infonet', cursor: 2 }],
|
||||
ts: 1712360301,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchSnapshotSpy).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
40,
|
||||
expect.objectContaining({ force: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const getNodeIdentity = vi.fn(() => null);
|
||||
const getWormholeIdentityDescriptor = vi.fn(() => ({ nodeId: '!sb_scope_a' }));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
getNodeIdentity,
|
||||
getWormholeIdentityDescriptor,
|
||||
}));
|
||||
|
||||
describe('gateCompatTelemetry', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
getNodeIdentity.mockReset();
|
||||
getNodeIdentity.mockReturnValue(null);
|
||||
getWormholeIdentityDescriptor.mockReset();
|
||||
getWormholeIdentityDescriptor.mockReturnValue({ nodeId: '!sb_scope_a' });
|
||||
});
|
||||
|
||||
it('records required and used compat events with reason summaries', async () => {
|
||||
const mod = await import('@/mesh/gateCompatTelemetry');
|
||||
|
||||
mod.recordGateCompatTelemetry({
|
||||
gateId: 'infonet',
|
||||
action: 'decrypt',
|
||||
reason: 'browser_gate_state_resync_required:infonet',
|
||||
kind: 'required',
|
||||
at: 1712500000000,
|
||||
});
|
||||
mod.recordGateCompatTelemetry({
|
||||
gateId: 'infonet',
|
||||
action: 'decrypt',
|
||||
reason: 'browser_gate_state_resync_required:infonet',
|
||||
kind: 'used',
|
||||
at: 1712500005000,
|
||||
});
|
||||
|
||||
const snapshot = mod.getGateCompatTelemetrySnapshot();
|
||||
|
||||
expect(snapshot.totalRequired).toBe(1);
|
||||
expect(snapshot.totalUsed).toBe(1);
|
||||
expect(snapshot.reasons[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
reason: 'browser_gate_state_resync_required:infonet',
|
||||
requiredCount: 1,
|
||||
usedCount: 1,
|
||||
recentGates: ['infonet'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps telemetry scoped to the current browser profile across reloads', async () => {
|
||||
const mod = await import('@/mesh/gateCompatTelemetry');
|
||||
|
||||
mod.recordGateCompatTelemetry({
|
||||
gateId: 'infonet',
|
||||
action: 'compose',
|
||||
reason: 'browser_gate_worker_unavailable',
|
||||
kind: 'required',
|
||||
at: 1712501000000,
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
getWormholeIdentityDescriptor.mockReturnValue({ nodeId: '!sb_scope_a' });
|
||||
|
||||
const reloaded = await import('@/mesh/gateCompatTelemetry');
|
||||
expect(reloaded.getGateCompatTelemetrySnapshot().totalRequired).toBe(1);
|
||||
|
||||
vi.resetModules();
|
||||
getWormholeIdentityDescriptor.mockReturnValue({ nodeId: '!sb_scope_b' });
|
||||
|
||||
const otherScope = await import('@/mesh/gateCompatTelemetry');
|
||||
expect(otherScope.getGateCompatTelemetrySnapshot().totalRequired).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -62,7 +62,13 @@ describe('gate envelope display', () => {
|
||||
|
||||
expect(isEncryptedGateEnvelope(encrypted)).toBe(true);
|
||||
expect(gateEnvelopeState(encrypted)).toBe('locked');
|
||||
expect(gateEnvelopeDisplayText(encrypted)).toBe('ENCRYPTED GATE MESSAGE - KEY UNAVAILABLE');
|
||||
expect(gateEnvelopeDisplayText(encrypted)).toBe('Sealed message - durable gate envelope was not stored.');
|
||||
expect(
|
||||
gateEnvelopeDisplayText({
|
||||
...encrypted,
|
||||
gate_envelope: 'opaque-envelope',
|
||||
}),
|
||||
).toBe('Sealed message - waiting for local gate decrypt.');
|
||||
expect(
|
||||
gateEnvelopeState({
|
||||
...encrypted,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* P5A: End-to-end gate envelope hash binding on the live decrypt path.
|
||||
*
|
||||
* Tests prove:
|
||||
* - normalizeInfoNetMessage preserves envelope_hash from payload
|
||||
* - normalizeInfoNetMessage preserves top-level envelope_hash
|
||||
* - legacy messages without envelope_hash are not broken
|
||||
* - WormholeGateDecryptPayload shape includes envelope_hash
|
||||
* - decryptWormholeGateMessage single-message helper accepts integrity fields
|
||||
* - MeshTerminal normalizer pattern preserves gate_envelope and envelope_hash
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { InfoNetMessage } from '@/components/MeshChat/types';
|
||||
import { normalizeInfoNetMessage } from '@/components/MeshChat/utils';
|
||||
import {
|
||||
decryptWormholeGateMessage,
|
||||
type WormholeGateDecryptPayload,
|
||||
} from '@/mesh/wormholeIdentityClient';
|
||||
|
||||
describe('normalizeInfoNetMessage preserves envelope_hash', () => {
|
||||
it('extracts envelope_hash from nested payload', () => {
|
||||
const raw: InfoNetMessage = {
|
||||
event_id: 'e1',
|
||||
timestamp: 1000,
|
||||
payload: {
|
||||
gate: 'finance',
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
format: 'mls1',
|
||||
envelope_hash: 'abc123hash',
|
||||
},
|
||||
};
|
||||
const normalized = normalizeInfoNetMessage(raw);
|
||||
expect(normalized.envelope_hash).toBe('abc123hash');
|
||||
});
|
||||
|
||||
it('preserves top-level envelope_hash over payload', () => {
|
||||
const raw: InfoNetMessage = {
|
||||
event_id: 'e2',
|
||||
timestamp: 2000,
|
||||
envelope_hash: 'top-level-hash',
|
||||
payload: {
|
||||
gate: 'finance',
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n2',
|
||||
sender_ref: 'sr2',
|
||||
format: 'mls1',
|
||||
envelope_hash: 'payload-hash',
|
||||
},
|
||||
};
|
||||
const normalized = normalizeInfoNetMessage(raw);
|
||||
expect(normalized.envelope_hash).toBe('top-level-hash');
|
||||
});
|
||||
|
||||
it('returns empty string when no envelope_hash present', () => {
|
||||
const raw: InfoNetMessage = {
|
||||
event_id: 'e3',
|
||||
timestamp: 3000,
|
||||
payload: {
|
||||
gate: 'finance',
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n3',
|
||||
sender_ref: 'sr3',
|
||||
format: 'mls1',
|
||||
},
|
||||
};
|
||||
const normalized = normalizeInfoNetMessage(raw);
|
||||
expect(normalized.envelope_hash).toBe('');
|
||||
});
|
||||
|
||||
it('does not break messages without payload', () => {
|
||||
const raw: InfoNetMessage = {
|
||||
event_id: 'e4',
|
||||
timestamp: 4000,
|
||||
ciphertext: 'ct',
|
||||
gate_envelope: 'env',
|
||||
envelope_hash: 'hash4',
|
||||
};
|
||||
const normalized = normalizeInfoNetMessage(raw);
|
||||
// No payload → returns message as-is, envelope_hash untouched
|
||||
expect(normalized.envelope_hash).toBe('hash4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WormholeGateDecryptPayload supports envelope_hash', () => {
|
||||
it('accepts envelope_hash in the payload type', () => {
|
||||
const payload: WormholeGateDecryptPayload = {
|
||||
gate_id: 'gate1',
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'env',
|
||||
envelope_hash: 'abc123hash',
|
||||
};
|
||||
expect(payload.envelope_hash).toBe('abc123hash');
|
||||
});
|
||||
|
||||
it('allows omitting envelope_hash for legacy compatibility', () => {
|
||||
const payload: WormholeGateDecryptPayload = {
|
||||
gate_id: 'gate1',
|
||||
ciphertext: 'ct',
|
||||
};
|
||||
expect(payload.envelope_hash).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decrypt caller payload construction includes envelope_hash', () => {
|
||||
it('builds decrypt payload with envelope_hash when present on message', () => {
|
||||
// Simulates the payload construction pattern used in GateView and useMeshChatController
|
||||
const message = {
|
||||
gate: 'finance',
|
||||
epoch: 2,
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'env-data',
|
||||
envelope_hash: 'sha256-hex-hash',
|
||||
};
|
||||
|
||||
const decryptPayload: WormholeGateDecryptPayload = {
|
||||
gate_id: String(message.gate || ''),
|
||||
epoch: Number(message.epoch || 0),
|
||||
ciphertext: String(message.ciphertext || ''),
|
||||
nonce: String(message.nonce || ''),
|
||||
sender_ref: String(message.sender_ref || ''),
|
||||
format: String(message.format || 'mls1'),
|
||||
gate_envelope: String(message.gate_envelope || ''),
|
||||
envelope_hash: String(message.envelope_hash || ''),
|
||||
};
|
||||
|
||||
expect(decryptPayload.envelope_hash).toBe('sha256-hex-hash');
|
||||
expect(decryptPayload.gate_envelope).toBe('env-data');
|
||||
});
|
||||
|
||||
it('builds decrypt payload with empty envelope_hash for legacy messages', () => {
|
||||
const message = {
|
||||
gate: 'finance',
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'env-data',
|
||||
};
|
||||
|
||||
const decryptPayload: WormholeGateDecryptPayload = {
|
||||
gate_id: String(message.gate || ''),
|
||||
epoch: 0,
|
||||
ciphertext: String(message.ciphertext || ''),
|
||||
nonce: String(message.nonce || ''),
|
||||
sender_ref: String(message.sender_ref || ''),
|
||||
format: String(message.format || 'mls1'),
|
||||
gate_envelope: String(message.gate_envelope || ''),
|
||||
envelope_hash: String((message as Record<string, unknown>).envelope_hash || ''),
|
||||
};
|
||||
|
||||
expect(decryptPayload.envelope_hash).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('single-message decryptWormholeGateMessage accepts integrity fields', () => {
|
||||
it('function signature accepts gate_envelope and envelope_hash', async () => {
|
||||
// Verify the function exists and accepts the extended signature.
|
||||
// We cannot call it without a running backend, but we can verify
|
||||
// the function shape by checking it is callable with 7 args.
|
||||
expect(typeof decryptWormholeGateMessage).toBe('function');
|
||||
expect(decryptWormholeGateMessage.length).toBeLessThanOrEqual(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshTerminal normalizeInfonetMessageRecord equivalent pattern', () => {
|
||||
it('preserves gate_envelope and envelope_hash from nested payload', () => {
|
||||
// Simulate the normalizeInfonetMessageRecord pattern from MeshTerminal
|
||||
const message: Record<string, unknown> = {
|
||||
event_id: 'e1',
|
||||
timestamp: 1000,
|
||||
payload: {
|
||||
gate: 'finance',
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'env-payload',
|
||||
envelope_hash: 'hash-payload',
|
||||
},
|
||||
};
|
||||
const payload = message.payload as Record<string, string> | undefined;
|
||||
const normalized = {
|
||||
...message,
|
||||
gate: String(message.gate ?? payload?.gate ?? ''),
|
||||
ciphertext: String(message.ciphertext ?? payload?.ciphertext ?? ''),
|
||||
nonce: String(message.nonce ?? payload?.nonce ?? ''),
|
||||
sender_ref: String(message.sender_ref ?? payload?.sender_ref ?? ''),
|
||||
format: String(message.format ?? payload?.format ?? ''),
|
||||
gate_envelope: String(message.gate_envelope ?? payload?.gate_envelope ?? ''),
|
||||
envelope_hash: String(message.envelope_hash ?? payload?.envelope_hash ?? ''),
|
||||
};
|
||||
expect(normalized.gate_envelope).toBe('env-payload');
|
||||
expect(normalized.envelope_hash).toBe('hash-payload');
|
||||
});
|
||||
|
||||
it('single decrypt call site passes integrity fields through', () => {
|
||||
const normalized = {
|
||||
gate: 'finance',
|
||||
epoch: 2,
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
gate_envelope: 'env-data',
|
||||
envelope_hash: 'sha256-hex',
|
||||
};
|
||||
// Matches the call pattern in describeGateMessage
|
||||
const args = [
|
||||
String(normalized.gate || ''),
|
||||
Number(normalized.epoch || 0),
|
||||
String(normalized.ciphertext || ''),
|
||||
String(normalized.nonce || ''),
|
||||
String(normalized.sender_ref || ''),
|
||||
String(normalized.gate_envelope || ''),
|
||||
String(normalized.envelope_hash || ''),
|
||||
];
|
||||
expect(args[5]).toBe('env-data');
|
||||
expect(args[6]).toBe('sha256-hex');
|
||||
});
|
||||
|
||||
it('legacy message without integrity fields produces empty strings', () => {
|
||||
const normalized = {
|
||||
gate: 'finance',
|
||||
epoch: 1,
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
};
|
||||
const args = [
|
||||
String(normalized.gate || ''),
|
||||
Number(normalized.epoch || 0),
|
||||
String(normalized.ciphertext || ''),
|
||||
String(normalized.nonce || ''),
|
||||
String(normalized.sender_ref || ''),
|
||||
String((normalized as any).gate_envelope || ''),
|
||||
String((normalized as any).envelope_hash || ''),
|
||||
];
|
||||
expect(args[5]).toBe('');
|
||||
expect(args[6]).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hasLocalControlBridge = vi.fn(() => false);
|
||||
const buildGateAccessHeaders = vi.fn();
|
||||
|
||||
vi.mock('@/lib/localControlTransport', () => ({
|
||||
hasLocalControlBridge,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/gateAccessProof', () => ({
|
||||
buildGateAccessHeaders,
|
||||
}));
|
||||
|
||||
describe('gateMessageSnapshot cache', () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
fetchMock.mockReset();
|
||||
buildGateAccessHeaders.mockReset();
|
||||
hasLocalControlBridge.mockReset();
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
buildGateAccessHeaders.mockResolvedValue({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
it('coarsens browser/web gate message reads through a short shared cache window', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T23:45:00.000Z'));
|
||||
try {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-1', gate: 'infonet', timestamp: 1712360000 }],
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
await expect(mod.fetchGateMessageSnapshot('infonet', 20)).resolves.toEqual([
|
||||
expect.objectContaining({ event_id: 'evt-1', gate: 'infonet' }),
|
||||
]);
|
||||
await mod.fetchGateMessageSnapshot('infonet', 20);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(10_001);
|
||||
|
||||
await mod.fetchGateMessageSnapshot('infonet', 20);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses a larger cached limit for smaller reads without another fetch', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: Array.from({ length: 8 }, (_, index) => ({
|
||||
event_id: `evt-${index + 1}`,
|
||||
gate: 'finance',
|
||||
timestamp: 1712360000 + index,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
await expect(mod.fetchGateMessageSnapshot('finance', 8)).resolves.toHaveLength(8);
|
||||
await expect(mod.fetchGateMessageSnapshot('finance', 4)).resolves.toHaveLength(4);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses session-stream proof reuse for stream-owned snapshot refreshes', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-1', gate: 'finance', timestamp: 1712360000 }],
|
||||
cursor: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
await expect(
|
||||
mod.fetchGateMessageSnapshotState('finance', 20, { proofMode: 'session_stream' }),
|
||||
).resolves.toEqual({
|
||||
messages: [expect.objectContaining({ event_id: 'evt-1', gate: 'finance' })],
|
||||
cursor: 1,
|
||||
});
|
||||
|
||||
expect(buildGateAccessHeaders).toHaveBeenCalledWith('finance', { mode: 'session_stream' });
|
||||
});
|
||||
|
||||
it('reuses a larger in-flight snapshot fetch for a smaller concurrent read', async () => {
|
||||
let releaseFetch:
|
||||
| ((value: {
|
||||
ok: true;
|
||||
json: () => Promise<{
|
||||
messages: Array<{ event_id: string; gate: string; timestamp: number }>;
|
||||
cursor: number;
|
||||
}>;
|
||||
}) => void)
|
||||
| null = null;
|
||||
fetchMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseFetch = resolve as typeof releaseFetch;
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
const larger = mod.fetchGateMessageSnapshotState('infonet', 40);
|
||||
const smaller = mod.fetchGateMessageSnapshotState('infonet', 20);
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/api/mesh/infonet/messages?gate=infonet&limit=40');
|
||||
|
||||
releaseFetch?.({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: Array.from({ length: 3 }, (_, index) => ({
|
||||
event_id: `evt-${index + 1}`,
|
||||
gate: 'infonet',
|
||||
timestamp: 1712360000 + index,
|
||||
})),
|
||||
cursor: 3,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(larger).resolves.toEqual({
|
||||
messages: [
|
||||
expect.objectContaining({ event_id: 'evt-1', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-2', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-3', gate: 'infonet' }),
|
||||
],
|
||||
cursor: 3,
|
||||
});
|
||||
await expect(smaller).resolves.toEqual({
|
||||
messages: [
|
||||
expect.objectContaining({ event_id: 'evt-1', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-2', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-3', gate: 'infonet' }),
|
||||
],
|
||||
cursor: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a shorter native cache window and supports explicit invalidation', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T23:45:00.000Z'));
|
||||
try {
|
||||
hasLocalControlBridge.mockReturnValue(true);
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-1', gate: 'ops', timestamp: 1712360000 }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-2', gate: 'ops', timestamp: 1712360010 }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-3', gate: 'ops', timestamp: 1712360020 }],
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
await expect(mod.fetchGateMessageSnapshot('ops', 6)).resolves.toEqual([
|
||||
expect.objectContaining({ event_id: 'evt-1' }),
|
||||
]);
|
||||
vi.advanceTimersByTime(3_001);
|
||||
await expect(mod.fetchGateMessageSnapshot('ops', 6)).resolves.toEqual([
|
||||
expect.objectContaining({ event_id: 'evt-2' }),
|
||||
]);
|
||||
|
||||
mod.invalidateGateMessageSnapshot('ops');
|
||||
await expect(mod.fetchGateMessageSnapshot('ops', 6)).resolves.toEqual([
|
||||
expect.objectContaining({ event_id: 'evt-3' }),
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('tracks cursors and waits for gate changes without re-reading the ordinary route', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-1', gate: 'infonet', timestamp: 1712360000 }],
|
||||
cursor: 1,
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{ event_id: 'evt-2', gate: 'infonet', timestamp: 1712360010 },
|
||||
{ event_id: 'evt-1', gate: 'infonet', timestamp: 1712360000 },
|
||||
],
|
||||
cursor: 2,
|
||||
changed: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
await expect(mod.fetchGateMessageSnapshotState('infonet', 20)).resolves.toEqual({
|
||||
messages: [expect.objectContaining({ event_id: 'evt-1', gate: 'infonet' })],
|
||||
cursor: 1,
|
||||
});
|
||||
await expect(mod.waitForGateMessageSnapshot('infonet', 1, 20, { timeoutMs: 18_000 })).resolves.toEqual({
|
||||
messages: [
|
||||
expect.objectContaining({ event_id: 'evt-2', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-1', gate: 'infonet' }),
|
||||
],
|
||||
cursor: 2,
|
||||
changed: true,
|
||||
});
|
||||
expect(mod.getGateMessageSnapshotCursor('infonet')).toBe(2);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toContain('/api/mesh/infonet/messages/wait?gate=infonet&after=1');
|
||||
});
|
||||
|
||||
it('coalesces concurrent gate wait requests for the same gate cursor', async () => {
|
||||
let releaseWait:
|
||||
| ((value: { ok: true; json: () => Promise<{ messages: Array<{ event_id: string; gate: string; timestamp: number }>; cursor: number; changed: boolean }> }) => void)
|
||||
| null = null;
|
||||
fetchMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseWait = resolve as typeof releaseWait;
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
const first = mod.waitForGateMessageSnapshot('infonet', 4, 20, { timeoutMs: 18_000 });
|
||||
const second = mod.waitForGateMessageSnapshot('infonet', 4, 20, { timeoutMs: 24_000 });
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/api/mesh/infonet/messages/wait?gate=infonet&after=4');
|
||||
|
||||
releaseWait?.({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-5', gate: 'infonet', timestamp: 1712360050 }],
|
||||
cursor: 5,
|
||||
changed: true,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(first).resolves.toEqual({
|
||||
messages: [expect.objectContaining({ event_id: 'evt-5', gate: 'infonet' })],
|
||||
cursor: 5,
|
||||
changed: true,
|
||||
});
|
||||
await expect(second).resolves.toEqual({
|
||||
messages: [expect.objectContaining({ event_id: 'evt-5', gate: 'infonet' })],
|
||||
cursor: 5,
|
||||
changed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses a larger in-flight gate wait for a smaller concurrent consumer', async () => {
|
||||
let releaseWait:
|
||||
| ((value: {
|
||||
ok: true;
|
||||
json: () => Promise<{
|
||||
messages: Array<{ event_id: string; gate: string; timestamp: number }>;
|
||||
cursor: number;
|
||||
changed: boolean;
|
||||
}>;
|
||||
}) => void)
|
||||
| null = null;
|
||||
fetchMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseWait = resolve as typeof releaseWait;
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
const larger = mod.waitForGateMessageSnapshot('infonet', 4, 40, { timeoutMs: 18_000 });
|
||||
const smaller = mod.waitForGateMessageSnapshot('infonet', 4, 20, { timeoutMs: 24_000 });
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/api/mesh/infonet/messages/wait?gate=infonet&after=4&limit=40');
|
||||
|
||||
releaseWait?.({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{ event_id: 'evt-8', gate: 'infonet', timestamp: 1712360080 },
|
||||
{ event_id: 'evt-7', gate: 'infonet', timestamp: 1712360070 },
|
||||
],
|
||||
cursor: 8,
|
||||
changed: true,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(larger).resolves.toEqual({
|
||||
messages: [
|
||||
expect.objectContaining({ event_id: 'evt-8', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-7', gate: 'infonet' }),
|
||||
],
|
||||
cursor: 8,
|
||||
changed: true,
|
||||
});
|
||||
await expect(smaller).resolves.toEqual({
|
||||
messages: [
|
||||
expect.objectContaining({ event_id: 'evt-8', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-7', gate: 'infonet' }),
|
||||
],
|
||||
cursor: 8,
|
||||
changed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hasLocalControlBridge = vi.fn(() => false);
|
||||
|
||||
vi.mock('@/lib/localControlTransport', () => ({
|
||||
hasLocalControlBridge,
|
||||
}));
|
||||
|
||||
describe('gate metadata timing policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
hasLocalControlBridge.mockReset();
|
||||
});
|
||||
|
||||
it('jittered browser/web polling avoids an exact cadence', async () => {
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
const mod = await import('@/mesh/gateMetadataTiming');
|
||||
const pollDelays = Array.from({ length: 12 }, () => mod.nextGateMessagesPollDelayMs());
|
||||
expect(pollDelays.every((delay) => delay >= 24_000 && delay <= 36_000)).toBe(true);
|
||||
expect(new Set(pollDelays).size).toBeGreaterThan(1);
|
||||
const waitTimeouts = Array.from({ length: 12 }, () => mod.nextGateMessagesWaitTimeoutMs());
|
||||
expect(waitTimeouts.every((delay) => delay >= 26_000 && delay <= 38_000)).toBe(true);
|
||||
expect(new Set(waitTimeouts).size).toBeGreaterThan(1);
|
||||
const rearmDelays = Array.from({ length: 12 }, () => mod.nextGateMessagesWaitRearmDelayMs());
|
||||
expect(rearmDelays.every((delay) => delay >= 3_000 && delay <= 4_200)).toBe(true);
|
||||
expect(new Set(rearmDelays).size).toBeGreaterThan(1);
|
||||
const refreshDelays = Array.from({ length: 12 }, () => mod.nextGateActivityRefreshDelayMs());
|
||||
expect(refreshDelays.every((delay) => delay >= 4_500 && delay <= 9_500)).toBe(true);
|
||||
expect(new Set(refreshDelays).size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('native desktop keeps the tighter poll/send timing path', async () => {
|
||||
hasLocalControlBridge.mockReturnValue(true);
|
||||
const mod = await import('@/mesh/gateMetadataTiming');
|
||||
expect(mod.shouldJitterGateMetadataTiming()).toBe(false);
|
||||
expect(mod.nextGateMessagesPollDelayMs()).toBe(30_000);
|
||||
expect(mod.nextGateMessagesWaitTimeoutMs()).toBe(20_000);
|
||||
expect(mod.nextGateMessagesWaitRearmDelayMs()).toBe(750);
|
||||
expect(mod.nextGateActivityRefreshDelayMs()).toBe(0);
|
||||
});
|
||||
|
||||
it('coarsens hidden browser tab gate polling further', async () => {
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
const originalVisibility = Object.getOwnPropertyDescriptor(document, 'visibilityState');
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
value: 'hidden',
|
||||
});
|
||||
try {
|
||||
const mod = await import('@/mesh/gateMetadataTiming');
|
||||
const pollDelays = Array.from({ length: 12 }, () => mod.nextGateMessagesPollDelayMs());
|
||||
expect(pollDelays.every((delay) => delay >= 48_000 && delay <= 72_000)).toBe(true);
|
||||
const waitTimeouts = Array.from({ length: 12 }, () => mod.nextGateMessagesWaitTimeoutMs());
|
||||
expect(waitTimeouts.every((delay) => delay >= 60_000 && delay <= 84_000)).toBe(true);
|
||||
const rearmDelays = Array.from({ length: 12 }, () => mod.nextGateMessagesWaitRearmDelayMs());
|
||||
expect(rearmDelays.every((delay) => delay >= 6_000 && delay <= 12_000)).toBe(true);
|
||||
const refreshDelays = Array.from({ length: 12 }, () => mod.nextGateActivityRefreshDelayMs());
|
||||
expect(refreshDelays.every((delay) => delay >= 14_000 && delay <= 22_000)).toBe(true);
|
||||
} finally {
|
||||
if (originalVisibility) {
|
||||
Object.defineProperty(document, 'visibilityState', originalVisibility);
|
||||
} else {
|
||||
delete (document as Document & { visibilityState?: string }).visibilityState;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hasLocalControlBridge = vi.fn(() => false);
|
||||
const buildGateAccessHeaders = vi.fn();
|
||||
const decryptWormholeGateMessage = vi.fn();
|
||||
|
||||
vi.mock('@/lib/localControlTransport', () => ({
|
||||
hasLocalControlBridge,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/gateAccessProof', () => ({
|
||||
buildGateAccessHeaders,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
decryptWormholeGateMessage,
|
||||
}));
|
||||
|
||||
describe('gatePreviewSnapshot cache', () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
fetchMock.mockReset();
|
||||
buildGateAccessHeaders.mockReset();
|
||||
decryptWormholeGateMessage.mockReset();
|
||||
hasLocalControlBridge.mockReset();
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
buildGateAccessHeaders.mockResolvedValue({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
decryptWormholeGateMessage.mockResolvedValue({
|
||||
ok: true,
|
||||
plaintext: 'sealed preview',
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
it('coarsens browser/web gate preview fetches through a short cache window', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T23:30:00.000Z'));
|
||||
try {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
event_type: 'gate_message',
|
||||
node_id: '!sb_sender',
|
||||
gate: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'ct',
|
||||
nonce: 'nonce',
|
||||
sender_ref: 'sender-ref',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'env',
|
||||
envelope_hash: 'hash',
|
||||
timestamp: Math.floor(Date.now() / 1000) - 60,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gatePreviewSnapshot');
|
||||
|
||||
await expect(mod.fetchGateThreadPreviewSnapshot('infonet')).resolves.toEqual([
|
||||
{
|
||||
nodeId: '!sb_sender',
|
||||
age: '1m ago',
|
||||
text: 'sealed preview',
|
||||
encrypted: true,
|
||||
},
|
||||
]);
|
||||
await mod.fetchGateThreadPreviewSnapshot('infonet');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(decryptWormholeGateMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(12_001);
|
||||
|
||||
await mod.fetchGateThreadPreviewSnapshot('infonet');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a shorter preview cache window on native runtimes', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T23:30:00.000Z'));
|
||||
try {
|
||||
hasLocalControlBridge.mockReturnValue(true);
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
node_id: '!sb_sender',
|
||||
message: 'plain preview',
|
||||
timestamp: Math.floor(Date.now() / 1000) - 60,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gatePreviewSnapshot');
|
||||
|
||||
await mod.fetchGateThreadPreviewSnapshot('infonet');
|
||||
vi.advanceTimersByTime(4_001);
|
||||
await mod.fetchGateThreadPreviewSnapshot('infonet');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('invalidates cached gate previews explicitly', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
node_id: '!sb_sender',
|
||||
message: 'plain preview',
|
||||
timestamp: 1712360000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-2',
|
||||
node_id: '!sb_sender',
|
||||
message: 'updated preview',
|
||||
timestamp: 1712360100,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gatePreviewSnapshot');
|
||||
|
||||
await expect(mod.fetchGateThreadPreviewSnapshot('infonet')).resolves.toEqual([
|
||||
expect.objectContaining({ text: 'plain preview' }),
|
||||
]);
|
||||
mod.invalidateGateThreadPreviewSnapshot('infonet');
|
||||
await expect(mod.fetchGateThreadPreviewSnapshot('infonet')).resolves.toEqual([
|
||||
expect.objectContaining({ text: 'updated preview' }),
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const controlPlaneFetch = vi.fn();
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneFetch,
|
||||
}));
|
||||
|
||||
describe('gateSessionStream manager', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
controlPlaneFetch.mockReset();
|
||||
});
|
||||
|
||||
it('marks the stream disabled when the backend feature flag is off', async () => {
|
||||
controlPlaneFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: false, detail: 'gate_session_stream_disabled' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateSessionStream');
|
||||
|
||||
mod.connectGateSessionStream();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(mod.getGateSessionStreamStatus()).toMatchObject({
|
||||
enabled: false,
|
||||
phase: 'disabled',
|
||||
detail: 'gate_session_stream_disabled',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses hello and heartbeat events from the session stream skeleton', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
controlPlaneFetch.mockResolvedValue(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
[
|
||||
'event: hello',
|
||||
'data: {"session_id":"sess-1","subscriptions":["alpha","beta"],"heartbeat_s":20,"batch_ms":1500,"transport":"sse","gate_access":{"alpha":{"node_id":"!node_alpha","proof":"proof-alpha","ts":"1712360000"}},"gate_key_status":{"alpha":{"ok":true,"gate_id":"alpha","current_epoch":7,"has_local_access":true}}}',
|
||||
'',
|
||||
'event: heartbeat',
|
||||
'data: {"session_id":"sess-1","ts":1712360000}',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateSessionStream');
|
||||
|
||||
mod.setGateSessionStreamSubscriptions(['Alpha', 'beta']);
|
||||
mod.connectGateSessionStream();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(controlPlaneFetch).toHaveBeenCalledWith(
|
||||
'/api/mesh/infonet/session-stream?gates=alpha%2Cbeta',
|
||||
expect.objectContaining({
|
||||
requireAdminSession: true,
|
||||
cache: 'no-store',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
}),
|
||||
);
|
||||
expect(mod.getGateSessionStreamStatus()).toMatchObject({
|
||||
enabled: false,
|
||||
phase: 'closed',
|
||||
sessionId: 'sess-1',
|
||||
subscriptions: ['alpha', 'beta'],
|
||||
heartbeatS: 20,
|
||||
batchMs: 1500,
|
||||
lastEventType: 'heartbeat',
|
||||
});
|
||||
expect(mod.getGateSessionStreamAccessHeaders('alpha')).toEqual({
|
||||
'X-Wormhole-Node-Id': '!node_alpha',
|
||||
'X-Wormhole-Gate-Proof': 'proof-alpha',
|
||||
'X-Wormhole-Gate-Ts': '1712360000',
|
||||
});
|
||||
expect(mod.getGateSessionStreamKeyStatus('alpha')).toEqual({
|
||||
ok: true,
|
||||
gate_id: 'alpha',
|
||||
current_epoch: 7,
|
||||
has_local_access: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('retains one shared subscription set across multiple same-gate consumers', async () => {
|
||||
controlPlaneFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: false, detail: 'gate_session_stream_disabled' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateSessionStream');
|
||||
|
||||
const releaseA = mod.retainGateSessionStreamGate('Alpha');
|
||||
const releaseB = mod.retainGateSessionStreamGate('alpha');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(controlPlaneFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mod.getGateSessionStreamStatus().subscriptions).toEqual(['alpha']);
|
||||
|
||||
releaseA();
|
||||
expect(mod.getGateSessionStreamStatus().subscriptions).toEqual(['alpha']);
|
||||
|
||||
releaseB();
|
||||
expect(mod.getGateSessionStreamStatus()).toMatchObject({
|
||||
phase: 'idle',
|
||||
subscriptions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('can invalidate cached per-gate stream bootstrap context without dropping the stream status', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
controlPlaneFetch.mockResolvedValue(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
[
|
||||
'event: hello',
|
||||
'data: {"session_id":"sess-ctx","subscriptions":["alpha"],"heartbeat_s":20,"batch_ms":1500,"transport":"sse","gate_access":{"alpha":{"node_id":"!node_alpha","proof":"proof-alpha","ts":"1712360000"}},"gate_key_status":{"alpha":{"ok":true,"gate_id":"alpha","current_epoch":7,"has_local_access":true}}}',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateSessionStream');
|
||||
|
||||
mod.retainGateSessionStreamGate('alpha');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(mod.getGateSessionStreamAccessHeaders('alpha')).toBeDefined();
|
||||
expect(mod.getGateSessionStreamKeyStatus('alpha')).toBeTruthy();
|
||||
|
||||
mod.invalidateGateSessionStreamGateContext('alpha');
|
||||
|
||||
expect(mod.getGateSessionStreamAccessHeaders('alpha')).toBeUndefined();
|
||||
expect(mod.getGateSessionStreamKeyStatus('alpha')).toBeNull();
|
||||
expect(mod.getGateSessionStreamStatus().sessionId).toBe('sess-ctx');
|
||||
});
|
||||
|
||||
it('emits parsed gate_update events to stream event listeners', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
controlPlaneFetch.mockResolvedValue(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
[
|
||||
'event: hello',
|
||||
'data: {"session_id":"sess-2","subscriptions":["alpha"],"heartbeat_s":20,"batch_ms":1500,"transport":"sse"}',
|
||||
'',
|
||||
'event: gate_update',
|
||||
'data: {"session_id":"sess-2","updates":[{"gate_id":"alpha","cursor":3}],"ts":1712360001}',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateSessionStream');
|
||||
const events: Array<{ event: string; data: unknown }> = [];
|
||||
const unsubscribe = mod.subscribeGateSessionStreamEvents((event) => {
|
||||
events.push({ event: event.event, data: event.data });
|
||||
});
|
||||
|
||||
mod.retainGateSessionStreamGate('alpha');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
unsubscribe();
|
||||
|
||||
expect(events.some((event) => event.event === 'hello')).toBe(true);
|
||||
expect(events).toContainEqual({
|
||||
event: 'gate_update',
|
||||
data: {
|
||||
session_id: 'sess-2',
|
||||
updates: [{ gate_id: 'alpha', cursor: 3 }],
|
||||
ts: 1712360001,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('reconnects with retained subscriptions after the stream closes', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const encoder = new TextEncoder();
|
||||
let callCount = 0;
|
||||
controlPlaneFetch.mockImplementation(async () => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
[
|
||||
'event: hello',
|
||||
'data: {"session_id":"sess-3","subscriptions":["alpha"],"heartbeat_s":20,"batch_ms":1500,"transport":"sse"}',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
[
|
||||
'event: hello',
|
||||
'data: {"session_id":"sess-4","subscriptions":["alpha"],"heartbeat_s":20,"batch_ms":1500,"transport":"sse"}',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateSessionStream');
|
||||
const release = mod.retainGateSessionStreamGate('alpha');
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(controlPlaneFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mod.getGateSessionStreamStatus()).toMatchObject({
|
||||
enabled: true,
|
||||
subscriptions: ['alpha'],
|
||||
});
|
||||
expect(['connecting', 'open']).toContain(mod.getGateSessionStreamStatus().phase);
|
||||
|
||||
release();
|
||||
mod.disconnectGateSessionStream();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -155,4 +155,14 @@ describe('mailbox claim privacy padding', () => {
|
||||
expect(decoyTokens).toEqual(['decoy-0', 'decoy-1']);
|
||||
expect(decoyTokens.every((token) => !realSharedTokens.includes(token))).toBe(true);
|
||||
});
|
||||
|
||||
it('can build mailbox claims from a prepared Wormhole identity override', async () => {
|
||||
deadDropTokensForContacts.mockResolvedValue([]);
|
||||
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
await mod.buildMailboxClaims({}, { nodeId: '!sb_wormhole_dm' });
|
||||
|
||||
expect(mailboxClaimToken).toHaveBeenCalledWith('self', '!sb_wormhole_dm');
|
||||
expect(mailboxClaimToken).toHaveBeenCalledWith('requests', '!sb_wormhole_dm');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isDmPollBlocked, isGateSendBlocked, shouldQueueDmSend } from '@/lib/meshChatPolicies';
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
return fs.readFileSync(path.resolve(here, relativePath), 'utf-8');
|
||||
}
|
||||
|
||||
describe('MeshChat behavior - shouldQueueDmSend', () => {
|
||||
it('returns false for default privacy profile', () => {
|
||||
expect(shouldQueueDmSend('default')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for high privacy profile', () => {
|
||||
expect(shouldQueueDmSend('high')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshChat behavior - isGateSendBlocked', () => {
|
||||
it('blocks when on infonet tab with gate selected but access not ready', () => {
|
||||
expect(isGateSendBlocked('infonet', true, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not block when gate access is ready', () => {
|
||||
expect(isGateSendBlocked('infonet', true, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block when no gate is selected', () => {
|
||||
expect(isGateSendBlocked('infonet', false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block on non-infonet tabs', () => {
|
||||
expect(isGateSendBlocked('dms', true, false)).toBe(false);
|
||||
expect(isGateSendBlocked('meshtastic', true, false)).toBe(false);
|
||||
expect(isGateSendBlocked('mesh', true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block when all conditions are false', () => {
|
||||
expect(isGateSendBlocked('dms', false, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshChat behavior - isDmPollBlocked', () => {
|
||||
it('blocks when wormhole is enabled but not ready', () => {
|
||||
expect(isDmPollBlocked(true, false, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks when anonymous DM is blocked', () => {
|
||||
expect(isDmPollBlocked(false, false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks when both wormhole not ready and anonymous blocked', () => {
|
||||
expect(isDmPollBlocked(true, false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not block when wormhole is ready and anonymous is not blocked', () => {
|
||||
expect(isDmPollBlocked(true, true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block when wormhole is disabled and anonymous is not blocked', () => {
|
||||
expect(isDmPollBlocked(false, false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block when wormhole is disabled and ready', () => {
|
||||
expect(isDmPollBlocked(false, true, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshChat behavior - policy wiring', () => {
|
||||
it('controller imports all three policy functions from meshChatPolicies', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toMatch(
|
||||
/import\s*\{[^}]*shouldQueueDmSend[^}]*\}\s*from\s+['"]@\/lib\/meshChatPolicies['"]/,
|
||||
);
|
||||
expect(controller).toMatch(
|
||||
/import\s*\{[^}]*isGateSendBlocked[^}]*\}\s*from\s+['"]@\/lib\/meshChatPolicies['"]/,
|
||||
);
|
||||
expect(controller).toMatch(
|
||||
/import\s*\{[^}]*isDmPollBlocked[^}]*\}\s*from\s+['"]@\/lib\/meshChatPolicies['"]/,
|
||||
);
|
||||
});
|
||||
|
||||
it('controller calls shouldQueueDmSend in enqueueDmSend', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toContain('shouldQueueDmSend(privacyProfile)');
|
||||
});
|
||||
|
||||
it('controller calls isGateSendBlocked in handleSend', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toContain('isGateSendBlocked(');
|
||||
});
|
||||
|
||||
it('controller calls isDmPollBlocked in DM poll effects', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toContain(
|
||||
'isDmPollBlocked(wormholeEnabled, wormholeReadyState, anonymousDmBlocked)',
|
||||
);
|
||||
});
|
||||
|
||||
it('controller suppresses unread-count polling while the DMS tab owns mailbox refresh', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toContain("if (!hasId || !getDMNotify() || (expanded && activeTab === 'dms')) return;");
|
||||
expect(controller).toContain("jitteredPollDelay(baseDelay, { profile: privacyProfile })");
|
||||
});
|
||||
|
||||
it('controller uses the shared DM poll scheduler for live mailbox refresh cadence', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toContain('classifyTick(hasMore, catchUpBudget, DM_MESSAGES_POLL_MS');
|
||||
expect(controller).toContain('timer = setTimeout(() => void poll(classification.refreshCount), classification.delay);');
|
||||
});
|
||||
|
||||
it('dead-drop UI distinguishes invite-pinned trust from TOFU-only', () => {
|
||||
const index = readSource('../../components/MeshChat/index.tsx');
|
||||
expect(index).toContain('getContactTrustSummary');
|
||||
expect(index).toContain('INVITE PINNED');
|
||||
expect(index).toContain('TOFU ONLY');
|
||||
expect(index).toContain('anchored by an imported signed invite');
|
||||
expect(index).toContain('rootWitnessContinuityLabel');
|
||||
expect(index).toContain('RECOVER ROOT');
|
||||
expect(index).toContain('!selectedContactTrustSummary?.rootMismatch');
|
||||
});
|
||||
|
||||
it('request UI does not route ordinary request flow through legacy add-contact lookup', () => {
|
||||
const index = readSource('../../components/MeshChat/index.tsx');
|
||||
expect(index).toContain('handleRequestComposerAction');
|
||||
expect(index).not.toContain('handleAddContact().catch(() =>');
|
||||
expect(index).toContain('dm add');
|
||||
expect(index).toContain('legacy migration');
|
||||
});
|
||||
|
||||
it('controller blocks trust-new-key when the stable root changed', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toContain('contactInfo?.remotePrekeyRootMismatch');
|
||||
expect(controller).toContain('stable root changed; use RECOVER ROOT or replace the signed invite');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Sprint 4A regression tests — MeshChat decomposition boundary checks.
|
||||
*
|
||||
* These tests validate the frozen contract:
|
||||
* 1. High-privacy DM queueing lives in the controller
|
||||
* 2. selectedGateAccessReady gating lives in the controller
|
||||
* 3. DM polling trust-mutation code lives in the controller
|
||||
* 4. Gate refresh is controller-owned via authenticated poll (SSE removed in S3A)
|
||||
* 5. Identity persistence stays in meshIdentity.ts (not in presentational code)
|
||||
* 6. No direct trust-mutating imports in presentational components
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const MESH_CHAT_DIR = path.resolve(__dirname, '../../components/MeshChat');
|
||||
|
||||
function readFile(name: string): string {
|
||||
return fs.readFileSync(path.join(MESH_CHAT_DIR, name), 'utf-8');
|
||||
}
|
||||
|
||||
// ─── Trust-mutation isolation ───────────────────────────────────────────────
|
||||
|
||||
const TRUST_MUTATING_IMPORTS = [
|
||||
'addContact',
|
||||
'updateContact',
|
||||
'blockContact',
|
||||
'purgeBrowserSigningMaterial',
|
||||
'purgeBrowserContactGraph',
|
||||
'purgeBrowserDmState',
|
||||
];
|
||||
|
||||
describe('MeshChat decomposition — trust mutation isolation', () => {
|
||||
it('controller imports all trust-mutating functions', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
for (const fn of TRUST_MUTATING_IMPORTS) {
|
||||
expect(controller).toContain(fn);
|
||||
}
|
||||
});
|
||||
|
||||
it('presentational index.tsx does NOT import trust-mutating functions directly', () => {
|
||||
const index = readFile('index.tsx');
|
||||
for (const fn of TRUST_MUTATING_IMPORTS) {
|
||||
// Check that none of these appear in import statements
|
||||
const importPattern = new RegExp(
|
||||
`import\\s*\\{[^}]*\\b${fn}\\b[^}]*\\}\\s*from`,
|
||||
);
|
||||
expect(index).not.toMatch(importPattern);
|
||||
}
|
||||
});
|
||||
|
||||
it('presentational index.tsx does not import from meshIdentity', () => {
|
||||
const index = readFile('index.tsx');
|
||||
expect(index).not.toMatch(/from\s+['"]@\/mesh\/meshIdentity['"]/);
|
||||
});
|
||||
|
||||
it('presentational index.tsx does not import from meshDmWorkerClient', () => {
|
||||
const index = readFile('index.tsx');
|
||||
expect(index).not.toMatch(/from\s+['"]@\/mesh\/meshDmWorkerClient['"]/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Controller owns required-cohesion items ────────────────────────────────
|
||||
|
||||
describe('MeshChat decomposition — controller required-cohesion', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
|
||||
it('controller exports enqueueDmSend (high-privacy DM queueing)', () => {
|
||||
expect(controller).toMatch(/enqueueDmSend/);
|
||||
// Also in the return block
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*enqueueDmSend[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports flushDmQueue (high-privacy DM queueing)', () => {
|
||||
expect(controller).toMatch(/flushDmQueue/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*flushDmQueue[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports selectedGateAccessReady', () => {
|
||||
expect(controller).toMatch(/selectedGateAccessReady/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*selectedGateAccessReady[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports selectedGateKeyStatus', () => {
|
||||
expect(controller).toMatch(/selectedGateKeyStatus/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*selectedGateKeyStatus[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports native gate resync state and handler', () => {
|
||||
expect(controller).toMatch(/gateResyncTarget/);
|
||||
expect(controller).toMatch(/gateResyncBusy/);
|
||||
expect(controller).toMatch(/handleResyncGateState/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*gateResyncTarget[\s\S]*\}/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*gateResyncBusy[\s\S]*\}/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*handleResyncGateState[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports secureDmBlocked', () => {
|
||||
expect(controller).toMatch(/secureDmBlocked/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*secureDmBlocked[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports privacyProfile', () => {
|
||||
expect(controller).toMatch(/privacyProfile/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*privacyProfile[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports hasId and hasPublicLaneIdentity', () => {
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*hasId[\s\S]*\}/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*hasPublicLaneIdentity[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports publicMeshBlockedByWormhole', () => {
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*publicMeshBlockedByWormhole[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports anonymousPublicBlocked and anonymousDmBlocked', () => {
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*anonymousPublicBlocked[\s\S]*\}/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*anonymousDmBlocked[\s\S]*\}/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Gate refresh is controller-owned (SSE removed in S3A) ────────────────
|
||||
|
||||
describe('MeshChat decomposition — gate refresh ownership', () => {
|
||||
it('controller does NOT import useGateSSE (removed in S3A)', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
expect(controller).not.toMatch(/import.*useGateSSE.*from/);
|
||||
expect(controller).not.toMatch(/useGateSSE\(/);
|
||||
});
|
||||
|
||||
it('controller owns gate message polling via authenticated fetch', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
// The controller polls /api/mesh/infonet/messages for gate refresh
|
||||
expect(controller).toMatch(/\/api\/mesh\/infonet\/messages/);
|
||||
expect(controller).toMatch(/setInterval\(poll/);
|
||||
});
|
||||
|
||||
it('useGateSSE is NOT imported in the presentational shell', () => {
|
||||
const index = readFile('index.tsx');
|
||||
expect(index).not.toMatch(/useGateSSE/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DM polling trust unit controller-owned ─────────────────────────────────
|
||||
|
||||
describe('MeshChat decomposition — DM poll sequence in controller', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
|
||||
it('DM polling (pollDmMailboxes) is in the controller', () => {
|
||||
expect(controller).toMatch(/pollDmMailboxes/);
|
||||
});
|
||||
|
||||
it('decryptDM is called in the controller (DM decrypt)', () => {
|
||||
expect(controller).toMatch(/decryptDM/);
|
||||
});
|
||||
|
||||
it('ratchetDecryptDM is in the controller', () => {
|
||||
expect(controller).toMatch(/ratchetDecryptDM/);
|
||||
});
|
||||
|
||||
it('sender seal decryption is in the controller via storage import', () => {
|
||||
expect(controller).toMatch(/decryptSenderSealForContact/);
|
||||
});
|
||||
|
||||
it('contact mutation (addContact/updateContact) happens only in controller', () => {
|
||||
const index = readFile('index.tsx');
|
||||
// These should not appear as direct function calls in the view
|
||||
expect(index).not.toMatch(/\baddContact\s*\(/);
|
||||
expect(index).not.toMatch(/\bupdateContact\s*\(/);
|
||||
expect(index).not.toMatch(/\bblockContact\s*\(/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Identity persistence through meshIdentity.ts ───────────────────────────
|
||||
|
||||
describe('MeshChat decomposition — identity persistence', () => {
|
||||
it('controller imports identity functions from meshIdentity', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
expect(controller).toMatch(/from\s+['"]@\/mesh\/meshIdentity['"]/);
|
||||
expect(controller).toMatch(/getNodeIdentity/);
|
||||
expect(controller).toMatch(/generateNodeKeys/);
|
||||
expect(controller).toMatch(/signEvent/);
|
||||
});
|
||||
|
||||
it('storage module imports from meshIdentity for seal operations', () => {
|
||||
const storage = readFile('storage.ts');
|
||||
expect(storage).toMatch(/from\s+['"]@\/mesh\/meshIdentity['"]/);
|
||||
});
|
||||
|
||||
it('types module re-exports Contact and NodeIdentity from meshIdentity', () => {
|
||||
const types = readFile('types.ts');
|
||||
expect(types).toMatch(/Contact/);
|
||||
expect(types).toMatch(/NodeIdentity/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Re-export stability ────────────────────────────────────────────────────
|
||||
|
||||
describe('MeshChat decomposition — export stability', () => {
|
||||
it('MeshChat.tsx re-exports default from MeshChat/index', () => {
|
||||
const reExport = fs.readFileSync(
|
||||
path.resolve(MESH_CHAT_DIR, '../MeshChat.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(reExport).toMatch(/export\s*\{\s*default\s*\}\s*from\s+['"]\.\/MeshChat\/index['"]/);
|
||||
});
|
||||
|
||||
it('MeshChat.tsx re-exports MeshChatProps type', () => {
|
||||
const reExport = fs.readFileSync(
|
||||
path.resolve(MESH_CHAT_DIR, '../MeshChat.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(reExport).toMatch(/export\s+type\s*\{\s*MeshChatProps\s*\}/);
|
||||
});
|
||||
|
||||
it('index.tsx exports default MeshChat component', () => {
|
||||
const index = readFile('index.tsx');
|
||||
expect(index).toMatch(/export\s+default\s+MeshChat/);
|
||||
});
|
||||
|
||||
it('presentational shell exposes the gate resync affordance', () => {
|
||||
const index = readFile('index.tsx');
|
||||
expect(index).toContain('RESYNC GATE STATE');
|
||||
expect(index).toContain('handleResyncGateState(selectedGate)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Phase 6A: Residual Backlog & Hygiene Closeout tests.
|
||||
*
|
||||
* Validates:
|
||||
* 1. DECOY_KEY removal from types.ts causes no import/runtime regression
|
||||
* 2. build_controller.py and build_index.py are deleted
|
||||
* 3. promotePendingAlias no longer calls updateContact from storage.ts
|
||||
* 4. Alias-promotion behavior unchanged after controller applies returned delta
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const MESH_CHAT_DIR = path.resolve(__dirname, '../../components/MeshChat');
|
||||
|
||||
function readFile(name: string): string {
|
||||
return fs.readFileSync(path.join(MESH_CHAT_DIR, name), 'utf-8');
|
||||
}
|
||||
|
||||
function fileExists(name: string): boolean {
|
||||
return fs.existsSync(path.join(MESH_CHAT_DIR, name));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. DECOY_KEY removal causes no import/runtime regression
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DECOY_KEY deduplication', () => {
|
||||
it('types.ts does NOT export DECOY_KEY', () => {
|
||||
const types = readFile('types.ts');
|
||||
expect(types).not.toMatch(/export\s+(const|let|var)\s+DECOY_KEY/);
|
||||
});
|
||||
|
||||
it('storage.ts still exports DECOY_KEY as canonical location', () => {
|
||||
const storage = readFile('storage.ts');
|
||||
expect(storage).toMatch(/export\s+const\s+DECOY_KEY/);
|
||||
});
|
||||
|
||||
it('DECOY_KEY is importable from storage at runtime', async () => {
|
||||
const { DECOY_KEY } = await import('../../components/MeshChat/storage');
|
||||
expect(DECOY_KEY).toBe('sb_dm_decoy');
|
||||
});
|
||||
|
||||
it('no file imports DECOY_KEY from types', () => {
|
||||
const files = fs.readdirSync(MESH_CHAT_DIR).filter((f) => f.endsWith('.ts') || f.endsWith('.tsx'));
|
||||
for (const file of files) {
|
||||
const content = readFile(file);
|
||||
const importFromTypes = content.match(/import\s*\{[^}]*DECOY_KEY[^}]*\}\s*from\s*['"]\.\/types['"]/);
|
||||
expect(importFromTypes, `${file} should not import DECOY_KEY from types`).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. build_controller.py and build_index.py are deleted
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('stale generator scripts removed', () => {
|
||||
it('build_controller.py does not exist', () => {
|
||||
expect(fileExists('build_controller.py')).toBe(false);
|
||||
});
|
||||
|
||||
it('build_index.py does not exist', () => {
|
||||
expect(fileExists('build_index.py')).toBe(false);
|
||||
});
|
||||
|
||||
it('no build/test config references build_controller.py or build_index.py', () => {
|
||||
const packageJson = fs.readFileSync(
|
||||
path.resolve(MESH_CHAT_DIR, '../../../package.json'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(packageJson).not.toContain('build_controller.py');
|
||||
expect(packageJson).not.toContain('build_index.py');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. promotePendingAlias no longer calls updateContact from storage.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('promotePendingAlias decoupled from updateContact', () => {
|
||||
it('storage.ts does not import updateContact', () => {
|
||||
const storage = readFile('storage.ts');
|
||||
expect(storage).not.toMatch(/import\s*\{[^}]*updateContact[^}]*\}\s*from/);
|
||||
});
|
||||
|
||||
it('storage.ts does not import getContacts', () => {
|
||||
const storage = readFile('storage.ts');
|
||||
expect(storage).not.toMatch(/import\s*\{[^}]*getContacts[^}]*\}\s*from/);
|
||||
});
|
||||
|
||||
it('promotePendingAlias does not call updateContact', () => {
|
||||
const storage = readFile('storage.ts');
|
||||
// Extract the promotePendingAlias function body
|
||||
const fnStart = storage.indexOf('export function promotePendingAlias');
|
||||
expect(fnStart).toBeGreaterThan(-1);
|
||||
const fnBody = storage.slice(fnStart, storage.indexOf('\n}', fnStart) + 2);
|
||||
expect(fnBody).not.toContain('updateContact(');
|
||||
});
|
||||
|
||||
it('promotePendingAlias does not call getContacts', () => {
|
||||
const storage = readFile('storage.ts');
|
||||
const fnStart = storage.indexOf('export function promotePendingAlias');
|
||||
const fnBody = storage.slice(fnStart, storage.indexOf('\n}', fnStart) + 2);
|
||||
expect(fnBody).not.toContain('getContacts(');
|
||||
});
|
||||
|
||||
it('controller call sites apply updateContact after promotePendingAlias', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
// Both call sites should follow pattern: promotePendingAlias → updateContact
|
||||
const promotionCalls = controller.match(/const promotion = promotePendingAlias\(/g);
|
||||
expect(promotionCalls?.length).toBeGreaterThanOrEqual(2);
|
||||
const updateAfterPromotion = controller.match(
|
||||
/if \(promotion\) updateContact\([^,]+, promotion\.delta\.updates\)/g,
|
||||
);
|
||||
expect(updateAfterPromotion?.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Alias-promotion behavior unchanged (delta structure)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('alias-promotion delta correctness', () => {
|
||||
it('returns null when contact has no pendingSharedAlias', async () => {
|
||||
const { promotePendingAlias } = await import('../../components/MeshChat/storage');
|
||||
const contact = { sharedAlias: 'abc' } as any;
|
||||
const result = promotePendingAlias('test-id', contact);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when grace period has not expired', async () => {
|
||||
const { promotePendingAlias } = await import('../../components/MeshChat/storage');
|
||||
const contact = {
|
||||
pendingSharedAlias: 'new-alias',
|
||||
sharedAlias: 'old-alias',
|
||||
sharedAliasGraceUntil: Date.now() + 60_000,
|
||||
} as any;
|
||||
const result = promotePendingAlias('test-id', contact);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns delta with promoted contact when grace period expired', async () => {
|
||||
const { promotePendingAlias } = await import('../../components/MeshChat/storage');
|
||||
const contact = {
|
||||
pendingSharedAlias: 'new-alias',
|
||||
sharedAlias: 'old-alias',
|
||||
sharedAliasGraceUntil: Date.now() - 1000,
|
||||
previousSharedAliases: [],
|
||||
} as any;
|
||||
const result = promotePendingAlias('test-id', contact);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.delta.updates.sharedAlias).toBe('new-alias');
|
||||
expect(result!.delta.updates.pendingSharedAlias).toBeUndefined();
|
||||
expect(result!.delta.updates.sharedAliasGraceUntil).toBeUndefined();
|
||||
expect(result!.delta.updates.sharedAliasRotatedAt).toBeGreaterThan(0);
|
||||
expect(result!.delta.updates.previousSharedAliases).toContain('old-alias');
|
||||
expect(result!.promoted.sharedAlias).toBe('new-alias');
|
||||
expect(result!.promoted.pendingSharedAlias).toBeUndefined();
|
||||
});
|
||||
|
||||
it('promoted contact merges updates onto original contact', async () => {
|
||||
const { promotePendingAlias } = await import('../../components/MeshChat/storage');
|
||||
const contact = {
|
||||
dhPubKey: 'some-key',
|
||||
pendingSharedAlias: 'next',
|
||||
sharedAlias: 'current',
|
||||
sharedAliasGraceUntil: 0,
|
||||
previousSharedAliases: ['older'],
|
||||
} as any;
|
||||
const result = promotePendingAlias('test-id', contact);
|
||||
expect(result).not.toBeNull();
|
||||
// Original fields preserved
|
||||
expect(result!.promoted.dhPubKey).toBe('some-key');
|
||||
// Alias history includes both old aliases
|
||||
expect(result!.promoted.previousSharedAliases).toContain('current');
|
||||
expect(result!.promoted.previousSharedAliases).toContain('older');
|
||||
});
|
||||
});
|
||||
@@ -121,6 +121,11 @@ describe('meshIdentity contact storage hardening', () => {
|
||||
remotePrekeySequence: 3,
|
||||
remotePrekeySignedAt: 444,
|
||||
remotePrekeyMismatch: false,
|
||||
remotePrekeyTransparencyHead: 'head-1',
|
||||
remotePrekeyTransparencySize: 2,
|
||||
remotePrekeyTransparencySeenAt: 555,
|
||||
remotePrekeyTransparencyConflict: false,
|
||||
remotePrekeyLookupMode: 'legacy_agent_id',
|
||||
});
|
||||
const stored = await waitForEncryptedContacts();
|
||||
expect(String(stored ?? '')).toMatch(/^enc:/);
|
||||
@@ -137,6 +142,11 @@ describe('meshIdentity contact storage hardening', () => {
|
||||
expect(hydrated.alice.remotePrekeySequence).toBe(3);
|
||||
expect(hydrated.alice.remotePrekeySignedAt).toBe(444);
|
||||
expect(hydrated.alice.remotePrekeyMismatch).toBe(false);
|
||||
expect(hydrated.alice.remotePrekeyTransparencyHead).toBe('head-1');
|
||||
expect(hydrated.alice.remotePrekeyTransparencySize).toBe(2);
|
||||
expect(hydrated.alice.remotePrekeyTransparencySeenAt).toBe(555);
|
||||
expect(hydrated.alice.remotePrekeyTransparencyConflict).toBe(false);
|
||||
expect(hydrated.alice.remotePrekeyLookupMode).toBe('legacy_agent_id');
|
||||
});
|
||||
|
||||
it('migrates legacy plaintext contacts to encrypted storage on first hydrate', async () => {
|
||||
@@ -241,4 +251,17 @@ describe('meshIdentity contact storage hardening', () => {
|
||||
const rotated = await mailboxClaimToken('requests', '!sb_contacts123456');
|
||||
expect(rotated).not.toBe(first);
|
||||
});
|
||||
|
||||
it('rotates mailbox claim tokens across mailbox epochs', async () => {
|
||||
const { mailboxClaimToken } = await import('@/mesh/meshMailbox');
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
await provisionLocalIdentity(mod);
|
||||
|
||||
const first = await mailboxClaimToken('requests', '!sb_contacts123456', 100);
|
||||
const second = await mailboxClaimToken('requests', '!sb_contacts123456', 100);
|
||||
const rotated = await mailboxClaimToken('requests', '!sb_contacts123456', 21_700);
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(rotated).not.toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const deriveWormholeDeadDropTokens = vi.fn();
|
||||
const deriveWormholeDeadDropTokenPair = vi.fn();
|
||||
const isWormholeReady = vi.fn();
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
deriveWormholeDeadDropTokens,
|
||||
deriveWormholeDeadDropTokenPair,
|
||||
isWormholeReady,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
deriveSharedSecret: vi.fn(),
|
||||
getStoredNodeDescriptor: vi.fn(() => ({ nodeId: 'local-node' })),
|
||||
}));
|
||||
|
||||
describe('mesh dead-drop alias hygiene', () => {
|
||||
beforeEach(() => {
|
||||
deriveWormholeDeadDropTokens.mockReset();
|
||||
deriveWormholeDeadDropTokenPair.mockReset();
|
||||
isWormholeReady.mockReset();
|
||||
});
|
||||
|
||||
it('sends alias refs instead of the stable peer id when mailbox aliases exist', async () => {
|
||||
isWormholeReady.mockResolvedValue(true);
|
||||
deriveWormholeDeadDropTokens.mockResolvedValue({
|
||||
ok: true,
|
||||
tokens: [
|
||||
{ peer_id: 'peer_alpha', peer_ref: 'dmx_alpha', current: 'tok1', previous: 'tok0', epoch: 7 },
|
||||
],
|
||||
});
|
||||
|
||||
const { deadDropTokensForContacts } = await import('@/mesh/meshDeadDrop');
|
||||
const tokens = await deadDropTokensForContacts(
|
||||
{
|
||||
peer_alpha: {
|
||||
blocked: false,
|
||||
dhPubKey: 'dhpub_alpha',
|
||||
sharedAlias: 'dmx_alpha',
|
||||
previousSharedAliases: ['dmx_prev_alpha'],
|
||||
} as any,
|
||||
},
|
||||
24,
|
||||
);
|
||||
|
||||
expect(tokens).toEqual(['tok1', 'tok0']);
|
||||
expect(deriveWormholeDeadDropTokens).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
peer_id: 'peer_alpha',
|
||||
peer_dh_pub: 'dhpub_alpha',
|
||||
peer_refs: ['dmx_alpha', 'dmx_prev_alpha'],
|
||||
},
|
||||
],
|
||||
24,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the stable peer id only when no alias history exists', async () => {
|
||||
isWormholeReady.mockResolvedValue(true);
|
||||
deriveWormholeDeadDropTokens.mockResolvedValue({
|
||||
ok: true,
|
||||
tokens: [
|
||||
{ peer_id: 'peer_bravo', peer_ref: 'peer_bravo', current: 'tok2', previous: 'tok1', epoch: 8 },
|
||||
],
|
||||
});
|
||||
|
||||
const { deadDropTokensForContacts } = await import('@/mesh/meshDeadDrop');
|
||||
await deadDropTokensForContacts(
|
||||
{
|
||||
peer_bravo: {
|
||||
blocked: false,
|
||||
dhPubKey: 'dhpub_bravo',
|
||||
} as any,
|
||||
},
|
||||
24,
|
||||
);
|
||||
|
||||
expect(deriveWormholeDeadDropTokens).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
peer_id: 'peer_bravo',
|
||||
peer_dh_pub: 'dhpub_bravo',
|
||||
peer_refs: ['peer_bravo'],
|
||||
},
|
||||
],
|
||||
24,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('fetchDmPublicKey lookup posture', () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not use legacy agent-id lookup unless explicitly allowed', async () => {
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
|
||||
const result = await mod.fetchDmPublicKey('http://localhost:8000', '!sb_legacy');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses invite lookup handles without enabling legacy agent-id lookup', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
json: async () => ({ ok: true, dh_pub_key: 'peer-dh', lookup_mode: 'invite_lookup_handle' }),
|
||||
});
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
|
||||
const result = await mod.fetchDmPublicKey(
|
||||
'http://localhost:8000',
|
||||
'!sb_peer',
|
||||
'invite-handle-123',
|
||||
);
|
||||
|
||||
expect(result?.dh_pub_key).toBe('peer-dh');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8000/api/mesh/dm/pubkey?lookup_token=invite-handle-123',
|
||||
);
|
||||
});
|
||||
|
||||
it('still supports explicit legacy agent-id lookup for migration-only paths', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
json: async () => ({ ok: true, dh_pub_key: 'peer-dh', lookup_mode: 'legacy_agent_id' }),
|
||||
});
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
|
||||
const result = await mod.fetchDmPublicKey('http://localhost:8000', '!sb_legacy', undefined, {
|
||||
allowLegacyAgentId: true,
|
||||
});
|
||||
|
||||
expect(result?.dh_pub_key).toBe('peer-dh');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8000/api/mesh/dm/pubkey?agent_id=%21sb_legacy',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
allDmPeerIds,
|
||||
mailboxPeerRefs,
|
||||
buildAliasRotateMessage,
|
||||
buildAccessGrantedMessage,
|
||||
buildContactAcceptMessage,
|
||||
@@ -59,6 +60,17 @@ describe('mesh DM consent helpers', () => {
|
||||
expect(allDmPeerIds('node_public', { sharedAlias: 'node_public' })).toEqual(['node_public']);
|
||||
});
|
||||
|
||||
it('prefers alias history for mailbox refs and drops stable public id once aliasing exists', () => {
|
||||
expect(
|
||||
mailboxPeerRefs('node_public', {
|
||||
sharedAlias: 'dmx_current',
|
||||
pendingSharedAlias: 'dmx_next',
|
||||
previousSharedAliases: ['dmx_prev'],
|
||||
}),
|
||||
).toEqual(['dmx_current', 'dmx_next', 'dmx_prev']);
|
||||
expect(mailboxPeerRefs('node_public', { sharedAlias: '' })).toEqual(['node_public']);
|
||||
});
|
||||
|
||||
it('builds and parses alias rotation control payloads', () => {
|
||||
const message = buildAliasRotateMessage('dmx_next');
|
||||
expect(parseAliasRotateMessage(message)).toEqual({ shared_alias: 'dmx_next' });
|
||||
@@ -76,10 +88,38 @@ describe('mesh DM consent helpers', () => {
|
||||
});
|
||||
|
||||
it('keeps alias history compact and unique', () => {
|
||||
expect(mergeAliasHistory(['dmx_a', 'dmx_b', 'dmx_a', 'dmx_c', 'dmx_d'])).toEqual([
|
||||
'dmx_a',
|
||||
'dmx_b',
|
||||
]);
|
||||
expect(mergeAliasHistory(['dmx_a', 'dmx_b', 'dmx_a', 'dmx_c', 'dmx_d'], 3)).toEqual([
|
||||
'dmx_a',
|
||||
'dmx_b',
|
||||
'dmx_c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('bounds mailbox peer refs to 4 and excludes long tail', () => {
|
||||
expect(
|
||||
mailboxPeerRefs('node_public', {
|
||||
sharedAlias: 'dmx_current',
|
||||
pendingSharedAlias: 'dmx_next',
|
||||
previousSharedAliases: ['dmx_prev1', 'dmx_prev2', 'dmx_prev3'],
|
||||
}),
|
||||
).toEqual(['dmx_current', 'dmx_next', 'dmx_prev1', 'dmx_prev2']);
|
||||
});
|
||||
|
||||
it('bounds allDmPeerIds previous alias enumeration to 2', () => {
|
||||
const ids = allDmPeerIds('node_public', {
|
||||
sharedAlias: 'dmx_current',
|
||||
pendingSharedAlias: 'dmx_next',
|
||||
previousSharedAliases: ['dmx_prev1', 'dmx_prev2', 'dmx_prev3'],
|
||||
});
|
||||
// current + pending + at most 2 previous + peerId
|
||||
expect(ids).toContain('dmx_current');
|
||||
expect(ids).toContain('dmx_next');
|
||||
expect(ids).toContain('dmx_prev1');
|
||||
expect(ids).toContain('dmx_prev2');
|
||||
expect(ids).not.toContain('dmx_prev3');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const signMeshEvent = vi.fn();
|
||||
const issueWormholeDmSenderToken = vi.fn();
|
||||
const issueWormholeDmSenderTokens = vi.fn();
|
||||
const registerWormholeDmKey = vi.fn();
|
||||
const validateEventPayload = vi.fn(() => ({ ok: true, reason: 'ok' }));
|
||||
const nextSequence = vi.fn(() => 42);
|
||||
|
||||
vi.mock('@/mesh/meshDeadDrop', () => ({
|
||||
deadDropToken: vi.fn(async () => 'shared-token'),
|
||||
deadDropTokensForContacts: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshMailbox', () => ({
|
||||
mailboxClaimToken: vi.fn(async (type: string) => `${type}-token`),
|
||||
mailboxDecoySharedToken: vi.fn(async (index: number) => `decoy-${index}`),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
deriveSenderSealKey: vi.fn(),
|
||||
ensureDhKeysFresh: vi.fn(),
|
||||
deriveSharedKey: vi.fn(),
|
||||
encryptDM: vi.fn(),
|
||||
getDHAlgo: vi.fn(() => 'X25519'),
|
||||
getNodeIdentity: vi.fn(() => ({ nodeId: '!sb_self', publicKey: 'pub' })),
|
||||
getPublicKeyAlgo: vi.fn(() => 'Ed25519'),
|
||||
nextSequence,
|
||||
verifyNodeIdBindingFromPublicKey: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
buildWormholeSenderSeal: vi.fn(),
|
||||
getActiveSigningContext: vi.fn(async () => null),
|
||||
isWormholeSecureRequired: vi.fn(async () => false),
|
||||
issueWormholeDmSenderToken,
|
||||
issueWormholeDmSenderTokens,
|
||||
registerWormholeDmKey,
|
||||
signRawMeshMessage: vi.fn(),
|
||||
signMeshEvent,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshSchema', () => ({
|
||||
validateEventPayload,
|
||||
}));
|
||||
|
||||
describe('DM transport lock signing', () => {
|
||||
const fetchMock = vi.fn();
|
||||
const identity = {
|
||||
nodeId: '!sb_self',
|
||||
publicKey: 'pub',
|
||||
privateKey: 'priv',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
validateEventPayload.mockClear();
|
||||
nextSequence.mockClear();
|
||||
signMeshEvent.mockReset();
|
||||
issueWormholeDmSenderToken.mockReset();
|
||||
issueWormholeDmSenderTokens.mockReset();
|
||||
registerWormholeDmKey.mockReset();
|
||||
signMeshEvent.mockResolvedValue({
|
||||
context: {
|
||||
nodeId: '!sb_self',
|
||||
publicKey: 'pub',
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
},
|
||||
signature: 'sig',
|
||||
sequence: 42,
|
||||
protocolVersion: 'infonet/2',
|
||||
});
|
||||
issueWormholeDmSenderTokens.mockResolvedValue({ tokens: [] });
|
||||
issueWormholeDmSenderToken.mockResolvedValue({ sender_token: 'sender-token' });
|
||||
registerWormholeDmKey.mockResolvedValue({ ok: true });
|
||||
fetchMock.mockResolvedValue({ json: async () => ({ ok: true }) });
|
||||
});
|
||||
|
||||
it('signs and sends private_strong on DM sends', async () => {
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
|
||||
await mod.sendDmMessage({
|
||||
apiBase: 'http://localhost:8000',
|
||||
identity,
|
||||
recipientId: '!sb_peer',
|
||||
ciphertext: 'sealed',
|
||||
msgId: 'dm-test-1',
|
||||
timestamp: 123,
|
||||
deliveryClass: 'request',
|
||||
});
|
||||
|
||||
expect(signMeshEvent).toHaveBeenCalledWith(
|
||||
'dm_message',
|
||||
expect.objectContaining({ transport_lock: 'private_strong' }),
|
||||
42,
|
||||
);
|
||||
const body = JSON.parse(fetchMock.mock.calls.at(-1)?.[1]?.body as string);
|
||||
expect(body.transport_lock).toBe('private_strong');
|
||||
});
|
||||
|
||||
it('signs and sends private_strong on DM poll/count', async () => {
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
const claims = [{ type: 'requests' as const, token: 'request-token' }];
|
||||
|
||||
await mod.pollDmMailboxes('http://localhost:8000', identity, claims);
|
||||
await mod.countDmMailboxes('http://localhost:8000', identity, claims);
|
||||
|
||||
expect(signMeshEvent).toHaveBeenCalledWith(
|
||||
'dm_poll',
|
||||
expect.objectContaining({ transport_lock: 'private_strong' }),
|
||||
42,
|
||||
);
|
||||
expect(signMeshEvent).toHaveBeenCalledWith(
|
||||
'dm_count',
|
||||
expect.objectContaining({ transport_lock: 'private_strong' }),
|
||||
42,
|
||||
);
|
||||
const pollBody = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
const countBody = JSON.parse(fetchMock.mock.calls[1][1].body as string);
|
||||
expect(pollBody.transport_lock).toBe('private_strong');
|
||||
expect(countBody.transport_lock).toBe('private_strong');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const controlPlaneJson = vi.fn();
|
||||
const probeInlineGateCryptoSupport = vi.fn(async () => ({ supported: true, reason: '' }));
|
||||
const adoptInlineGateState = vi.fn(async (snapshot) => snapshot);
|
||||
const composeInlineGateMessage = vi.fn(async () => ({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
nonce: 'inline-nonce',
|
||||
}));
|
||||
const decryptInlineGateMessages = vi.fn(async () => [
|
||||
{
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
plaintext: 'sealed',
|
||||
reply_to: '',
|
||||
identity_scope: 'browser_privacy_core',
|
||||
},
|
||||
]);
|
||||
const forgetInlineGateState = vi.fn(async () => {});
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshGateLocalRuntime', () => ({
|
||||
probeInlineGateCryptoSupport,
|
||||
adoptInlineGateState,
|
||||
composeInlineGateMessage,
|
||||
decryptInlineGateMessages,
|
||||
forgetInlineGateState,
|
||||
}));
|
||||
|
||||
describe('meshGateWorkerClient inline fallback', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
controlPlaneJson.mockReset();
|
||||
probeInlineGateCryptoSupport.mockReset();
|
||||
adoptInlineGateState.mockReset();
|
||||
composeInlineGateMessage.mockReset();
|
||||
decryptInlineGateMessages.mockReset();
|
||||
forgetInlineGateState.mockReset();
|
||||
|
||||
probeInlineGateCryptoSupport.mockResolvedValue({ supported: true, reason: '' });
|
||||
adoptInlineGateState.mockImplementation(async (snapshot) => snapshot);
|
||||
composeInlineGateMessage.mockResolvedValue({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
nonce: 'inline-nonce',
|
||||
});
|
||||
decryptInlineGateMessages.mockResolvedValue([
|
||||
{
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
plaintext: 'sealed',
|
||||
reply_to: '',
|
||||
identity_scope: 'browser_privacy_core',
|
||||
},
|
||||
]);
|
||||
forgetInlineGateState.mockResolvedValue(undefined);
|
||||
|
||||
Object.defineProperty(globalThis, 'Worker', {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the inline runtime when the Worker transport is unavailable', async () => {
|
||||
controlPlaneJson
|
||||
.mockResolvedValueOnce({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
rust_state_blob_b64: 'blob',
|
||||
members: [],
|
||||
active_identity_scope: 'anonymous',
|
||||
active_persona_id: '',
|
||||
active_node_id: '!sb_local',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
sender_id: '!sb_gate',
|
||||
public_key: 'pub',
|
||||
public_key_algo: 'ed25519',
|
||||
protocol_version: 'sb-test',
|
||||
sequence: 3,
|
||||
signature: 'sig',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
nonce: 'inline-nonce',
|
||||
sender_ref: 'sender-ref',
|
||||
format: 'mls1',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/meshGateWorkerClient');
|
||||
|
||||
await expect(mod.syncBrowserGateState('infonet', { force: true })).resolves.toBe(true);
|
||||
await expect(mod.composeBrowserGateMessage('infonet', 'hello')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
ciphertext: 'inline-ciphertext',
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
mod.decryptBrowserGateMessages([
|
||||
{
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
},
|
||||
]),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
results: [
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
plaintext: 'sealed',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(probeInlineGateCryptoSupport).toHaveBeenCalled();
|
||||
expect(adoptInlineGateState).toHaveBeenCalled();
|
||||
expect(composeInlineGateMessage).toHaveBeenCalledWith('infonet', 'hello', '');
|
||||
expect(decryptInlineGateMessages).toHaveBeenCalledWith([
|
||||
{
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
},
|
||||
]);
|
||||
expect(mod.getBrowserGateLocalRuntimeStatus()).toEqual(
|
||||
expect.objectContaining({
|
||||
mode: 'inline',
|
||||
health: 'active',
|
||||
reason: 'browser_gate_worker_unavailable',
|
||||
}),
|
||||
);
|
||||
expect(mod.describeBrowserGateLocalRuntimeStatus(mod.getBrowserGateLocalRuntimeStatus())).toBe(
|
||||
'INLINE local gate runtime active (worker unavailable)',
|
||||
);
|
||||
expect(controlPlaneJson).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/wormhole/gate/state/export',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(controlPlaneJson).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/wormhole/gate/message/sign-encrypted',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
nonce: 'inline-nonce',
|
||||
format: 'mls1',
|
||||
reply_to: '',
|
||||
compat_reply_to: false,
|
||||
recovery_plaintext: 'hello',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to backend sealing when browser signing cannot return a durable gate envelope', async () => {
|
||||
controlPlaneJson
|
||||
.mockResolvedValueOnce({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
rust_state_blob_b64: 'blob',
|
||||
members: [],
|
||||
active_identity_scope: 'anonymous',
|
||||
active_persona_id: '',
|
||||
active_node_id: '!sb_local',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
sender_id: '!sb_gate',
|
||||
public_key: 'pub',
|
||||
public_key_algo: 'ed25519',
|
||||
protocol_version: 'sb-test',
|
||||
sequence: 3,
|
||||
signature: 'sig',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
nonce: 'inline-nonce',
|
||||
sender_ref: 'sender-ref',
|
||||
format: 'mls1',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
event_id: 'evt-backend-sealed',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/meshGateWorkerClient');
|
||||
|
||||
await expect(mod.syncBrowserGateState('infonet', { force: true })).resolves.toBe(true);
|
||||
await expect(mod.postBrowserGateMessage('infonet', 'hello durable', 'evt-parent-1')).resolves.toEqual({
|
||||
ok: true,
|
||||
event_id: 'evt-backend-sealed',
|
||||
});
|
||||
|
||||
expect(controlPlaneJson).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/wormhole/gate/message/post',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
gate_id: 'infonet',
|
||||
plaintext: 'hello durable',
|
||||
reply_to: 'evt-parent-1',
|
||||
compat_plaintext: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the selected inline runtime as degraded when a later local compose fails', async () => {
|
||||
controlPlaneJson.mockResolvedValueOnce({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
rust_state_blob_b64: 'blob',
|
||||
members: [],
|
||||
active_identity_scope: 'anonymous',
|
||||
active_persona_id: '',
|
||||
active_node_id: '!sb_local',
|
||||
});
|
||||
composeInlineGateMessage.mockRejectedValueOnce(new Error('worker_gate_wrap_key_missing'));
|
||||
|
||||
const mod = await import('@/mesh/meshGateWorkerClient');
|
||||
|
||||
await expect(mod.syncBrowserGateState('infonet', { force: true })).resolves.toBe(true);
|
||||
await expect(mod.composeBrowserGateMessage('infonet', 'hello')).resolves.toBeNull();
|
||||
|
||||
expect(mod.getBrowserGateCryptoFailureReason('infonet', 'compose')).toBe('worker_gate_wrap_key_missing');
|
||||
expect(mod.getBrowserGateLocalRuntimeStatus()).toEqual(
|
||||
expect.objectContaining({
|
||||
mode: 'inline',
|
||||
health: 'degraded',
|
||||
reason: 'worker_gate_wrap_key_missing',
|
||||
}),
|
||||
);
|
||||
expect(mod.describeBrowserGateLocalRuntimeStatus(mod.getBrowserGateLocalRuntimeStatus())).toBe(
|
||||
'INLINE local gate runtime degraded (secure storage unavailable)',
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses self-authored plaintext when local gate decrypt cannot reopen the just-posted ciphertext', async () => {
|
||||
controlPlaneJson
|
||||
.mockResolvedValueOnce({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
rust_state_blob_b64: 'blob',
|
||||
members: [],
|
||||
active_identity_scope: 'anonymous',
|
||||
active_persona_id: '',
|
||||
active_node_id: '!sb_local',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
sender_id: '!sb_gate',
|
||||
public_key: 'pub',
|
||||
public_key_algo: 'ed25519',
|
||||
protocol_version: 'sb-test',
|
||||
sequence: 3,
|
||||
signature: 'sig',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
nonce: 'inline-nonce',
|
||||
sender_ref: 'sender-ref',
|
||||
format: 'mls1',
|
||||
});
|
||||
decryptInlineGateMessages.mockResolvedValueOnce([
|
||||
{
|
||||
ok: false,
|
||||
gate_id: 'infonet',
|
||||
detail: 'gate_mls_decrypt_failed',
|
||||
},
|
||||
]);
|
||||
|
||||
const mod = await import('@/mesh/meshGateWorkerClient');
|
||||
|
||||
await expect(mod.syncBrowserGateState('infonet', { force: true })).resolves.toBe(true);
|
||||
await expect(mod.composeBrowserGateMessage('infonet', 'hello self', 'evt-parent-7')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
ciphertext: 'inline-ciphertext',
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
mod.decryptBrowserGateMessages([
|
||||
{
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
},
|
||||
]),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
results: [
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
plaintext: 'hello self',
|
||||
reply_to: 'evt-parent-7',
|
||||
identity_scope: 'browser_self_echo',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(mod.getBrowserGateLocalRuntimeStatus()).toEqual(
|
||||
expect.objectContaining({
|
||||
mode: 'inline',
|
||||
health: 'active',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type StoreRecord = Map<string, unknown>;
|
||||
type DbRecord = {
|
||||
version: number;
|
||||
stores: Map<string, StoreRecord>;
|
||||
};
|
||||
|
||||
const databases = new Map<string, DbRecord>();
|
||||
const deletedDatabases: string[] = [];
|
||||
|
||||
function domStringList(record: DbRecord): DOMStringList {
|
||||
return {
|
||||
contains: (name: string) => record.stores.has(name),
|
||||
item: (index: number) => Array.from(record.stores.keys())[index] ?? null,
|
||||
get length() {
|
||||
return record.stores.size;
|
||||
},
|
||||
} as DOMStringList;
|
||||
}
|
||||
|
||||
function makeRequest<T>(
|
||||
executor: (request: IDBRequest<T>) => void,
|
||||
tx?: IDBTransaction,
|
||||
): IDBRequest<T> {
|
||||
const request = {} as IDBRequest<T>;
|
||||
queueMicrotask(() => {
|
||||
executor(request);
|
||||
tx?.oncomplete?.(new Event('complete') as Event);
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
function makeObjectStore(record: DbRecord, name: string, tx: IDBTransaction): IDBObjectStore {
|
||||
const store = record.stores.get(name);
|
||||
if (!store) throw new Error(`missing object store ${name}`);
|
||||
return {
|
||||
get(key: IDBValidKey) {
|
||||
return makeRequest((request) => {
|
||||
(request as { result?: unknown }).result = store.get(String(key));
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
}, tx);
|
||||
},
|
||||
put(value: unknown, key?: IDBValidKey) {
|
||||
return makeRequest((request) => {
|
||||
store.set(String(key ?? ''), value);
|
||||
(request as { result?: unknown }).result = key;
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
}, tx);
|
||||
},
|
||||
delete(key: IDBValidKey) {
|
||||
return makeRequest((request) => {
|
||||
store.delete(String(key));
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
}, tx);
|
||||
},
|
||||
clear() {
|
||||
return makeRequest((request) => {
|
||||
store.clear();
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
}, tx);
|
||||
},
|
||||
} as unknown as IDBObjectStore;
|
||||
}
|
||||
|
||||
function makeTransaction(record: DbRecord): IDBTransaction {
|
||||
const tx = {
|
||||
oncomplete: null,
|
||||
onerror: null,
|
||||
onabort: null,
|
||||
objectStore: (name: string) => makeObjectStore(record, name, tx as unknown as IDBTransaction),
|
||||
} as unknown as IDBTransaction;
|
||||
return tx;
|
||||
}
|
||||
|
||||
function makeDb(name: string, record: DbRecord): IDBDatabase {
|
||||
return {
|
||||
name,
|
||||
version: record.version,
|
||||
objectStoreNames: domStringList(record),
|
||||
createObjectStore(storeName: string) {
|
||||
if (!record.stores.has(storeName)) {
|
||||
record.stores.set(storeName, new Map());
|
||||
}
|
||||
return {} as IDBObjectStore;
|
||||
},
|
||||
transaction(_storeName: string | string[]) {
|
||||
return makeTransaction(record);
|
||||
},
|
||||
close() {
|
||||
/* noop */
|
||||
},
|
||||
} as unknown as IDBDatabase;
|
||||
}
|
||||
|
||||
function createFakeIndexedDb() {
|
||||
return {
|
||||
open(name: string, version?: number) {
|
||||
const request = {} as IDBOpenDBRequest;
|
||||
queueMicrotask(() => {
|
||||
const resolvedVersion = Number(version || 1);
|
||||
let record = databases.get(name);
|
||||
const upgrading = !record || resolvedVersion > record.version;
|
||||
if (!record) {
|
||||
record = { version: resolvedVersion, stores: new Map() };
|
||||
databases.set(name, record);
|
||||
}
|
||||
if (upgrading) {
|
||||
record.version = resolvedVersion;
|
||||
(request as { result?: IDBDatabase }).result = makeDb(name, record);
|
||||
request.onupgradeneeded?.(new Event('upgradeneeded') as IDBVersionChangeEvent);
|
||||
}
|
||||
(request as { result?: IDBDatabase }).result = makeDb(name, record);
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
});
|
||||
return request;
|
||||
},
|
||||
deleteDatabase(name: string) {
|
||||
const request = {} as IDBOpenDBRequest;
|
||||
queueMicrotask(() => {
|
||||
deletedDatabases.push(name);
|
||||
databases.delete(name);
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
});
|
||||
return request;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ensureStore(name: string, version: number, storeName: string): StoreRecord {
|
||||
let record = databases.get(name);
|
||||
if (!record) {
|
||||
record = { version, stores: new Map() };
|
||||
databases.set(name, record);
|
||||
}
|
||||
record.version = Math.max(record.version, version);
|
||||
if (!record.stores.has(storeName)) {
|
||||
record.stores.set(storeName, new Map());
|
||||
}
|
||||
return record.stores.get(storeName)!;
|
||||
}
|
||||
|
||||
function getStoredValue(name: string, storeName: string, key: string): unknown {
|
||||
return databases.get(name)?.stores.get(storeName)?.get(key);
|
||||
}
|
||||
|
||||
describe('gate worker vault hardening', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
databases.clear();
|
||||
deletedDatabases.length = 0;
|
||||
Object.defineProperty(globalThis, 'indexedDB', {
|
||||
value: createFakeIndexedDb(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('persists worker gate state as an encrypted blob instead of raw state', async () => {
|
||||
const mod = await import('@/mesh/meshGateWorkerVault');
|
||||
const sample = {
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
rust_state_blob_b64: 'blob-private',
|
||||
members: [
|
||||
{
|
||||
persona_id: 'persona-a',
|
||||
node_id: '!sb_gate',
|
||||
identity_scope: 'persona',
|
||||
group_handle: 11,
|
||||
},
|
||||
],
|
||||
active_identity_scope: 'persona',
|
||||
active_persona_id: 'persona-a',
|
||||
active_node_id: '!sb_gate',
|
||||
};
|
||||
|
||||
await mod.writeWorkerGateState(sample);
|
||||
|
||||
const raw = getStoredValue(mod.WORKER_GATE_DB, 'gate_state', 'infonet');
|
||||
expect(typeof raw).toBe('string');
|
||||
expect(String(raw)).not.toContain('blob-private');
|
||||
expect(String(raw)).not.toContain('persona-a');
|
||||
|
||||
const loaded = await mod.readWorkerGateState('infonet');
|
||||
expect(loaded).toEqual(sample);
|
||||
});
|
||||
|
||||
it('migrates legacy plaintext gate state into encrypted storage on read', async () => {
|
||||
const legacyStore = ensureStore('sb_mesh_gate_worker', 1, 'gate_state');
|
||||
ensureStore('sb_mesh_gate_worker', 1, 'meta');
|
||||
legacyStore.set('infonet', {
|
||||
gate_id: 'infonet',
|
||||
epoch: 4,
|
||||
rust_state_blob_b64: 'legacy-blob',
|
||||
members: [],
|
||||
active_identity_scope: 'anonymous',
|
||||
active_persona_id: '',
|
||||
active_node_id: '!sb_legacy',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/meshGateWorkerVault');
|
||||
const loaded = await mod.readWorkerGateState('infonet');
|
||||
const raw = getStoredValue(mod.WORKER_GATE_DB, 'gate_state', 'infonet');
|
||||
|
||||
expect(loaded?.rust_state_blob_b64).toBe('legacy-blob');
|
||||
expect(typeof raw).toBe('string');
|
||||
expect(String(raw)).not.toContain('legacy-blob');
|
||||
});
|
||||
|
||||
it('drops stale encrypted gate state when the wrap key is missing so the room can resync cleanly', async () => {
|
||||
const gateStore = ensureStore('sb_mesh_gate_worker', 1, 'gate_state');
|
||||
gateStore.set('infonet', 'encrypted-state-that-cannot-be-opened');
|
||||
ensureStore('sb_mesh_gate_worker', 1, 'meta');
|
||||
|
||||
const mod = await import('@/mesh/meshGateWorkerVault');
|
||||
await expect(mod.readWorkerGateState('infonet')).resolves.toBeNull();
|
||||
expect(getStoredValue(mod.WORKER_GATE_DB, 'gate_state', 'infonet')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deleteWorkerGateDatabase removes the persisted gate vault', async () => {
|
||||
const mod = await import('@/mesh/meshGateWorkerVault');
|
||||
await mod.deleteWorkerGateDatabase();
|
||||
expect(deletedDatabases).toContain('sb_mesh_gate_worker');
|
||||
});
|
||||
});
|
||||
@@ -80,10 +80,11 @@ describe('mesh identity storage separation', () => {
|
||||
expect(mod.getWormholeIdentityDescriptor()).toBeNull();
|
||||
});
|
||||
|
||||
it('migrates legacy browser and Wormhole node ids to the current format', async () => {
|
||||
it('migrates stored browser and Wormhole node ids from 8-hex and 16-hex forms', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
const publicKey = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
||||
const currentNodeId = await mod.deriveNodeIdFromPublicKey(publicKey);
|
||||
const compatNodeId = currentNodeId.slice(0, '!sb_'.length + 16);
|
||||
|
||||
mod.cachePublicIdentity({
|
||||
nodeId: '!sb_deadbeef',
|
||||
@@ -108,5 +109,42 @@ describe('mesh identity storage separation', () => {
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
|
||||
mod.cachePublicIdentity({
|
||||
nodeId: compatNodeId,
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
mod.cacheWormholeIdentityDescriptor({
|
||||
nodeId: compatNodeId,
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
|
||||
await mod.migrateLegacyNodeIds();
|
||||
|
||||
expect(mod.getStoredNodeDescriptor()).toEqual({
|
||||
nodeId: currentNodeId,
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
expect(mod.getWormholeIdentityDescriptor()).toEqual({
|
||||
nodeId: currentNodeId,
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts 32-hex current node ids and 16-hex compatibility ids, but not 8-hex ids', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
const publicKey = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
||||
const currentNodeId = await mod.deriveNodeIdFromPublicKey(publicKey);
|
||||
const compatNodeId = currentNodeId.slice(0, '!sb_'.length + 16);
|
||||
|
||||
await expect(mod.verifyNodeIdBindingFromPublicKey(publicKey, currentNodeId)).resolves.toBe(true);
|
||||
await expect(mod.verifyNodeIdBindingFromPublicKey(publicKey, compatNodeId)).resolves.toBe(true);
|
||||
await expect(mod.verifyNodeIdBindingFromPublicKey(publicKey, '!sb_deadbeef')).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
buildDmTrustHint,
|
||||
buildPrivateLaneHint,
|
||||
dmTrustPrimaryActionLabel,
|
||||
hasKnownFirstContactAnchor,
|
||||
hasVerifiedFirstContactAnchor,
|
||||
isInvitePinnedFirstContact,
|
||||
isFirstContactTrustOnly,
|
||||
requiresVerifiedFirstContact,
|
||||
shortTrustFingerprint,
|
||||
shouldAutoRevealSasForTrust,
|
||||
} from '@/mesh/meshPrivacyHints';
|
||||
@@ -61,6 +65,149 @@ describe('meshPrivacyHints', () => {
|
||||
expect(shouldAutoRevealSasForTrust(contact)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats invite-pinned first contact as stronger than TOFU', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'anchored by signed invite',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'show_sas',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'quorum_witnessed',
|
||||
rootWitnessThreshold: 2,
|
||||
rootWitnessCount: 2,
|
||||
rootWitnessDomainCount: 1,
|
||||
rootWitnessProvenanceState: 'local_quorum',
|
||||
rootWitnessIndependentQuorumMet: false,
|
||||
rootMismatch: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(isInvitePinnedFirstContact(contact)).toBe(true);
|
||||
expect(isFirstContactTrustOnly(contact)).toBe(false);
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'ROOT LOCAL QUORUM',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('co-resident in one trust domain');
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('SHOW SAS');
|
||||
expect(shouldAutoRevealSasForTrust(contact)).toBe(false);
|
||||
});
|
||||
|
||||
it('distinguishes independent quorum provenance from local quorum', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'anchored by signed invite on independent quorum root',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'show_sas',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'quorum_witnessed',
|
||||
rootWitnessThreshold: 2,
|
||||
rootWitnessCount: 2,
|
||||
rootWitnessDomainCount: 2,
|
||||
rootWitnessProvenanceState: 'independent_quorum',
|
||||
rootWitnessIndependentQuorumMet: true,
|
||||
rootMismatch: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'ROOT INDEPENDENT QUORUM',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('independently quorum-witnessed');
|
||||
});
|
||||
|
||||
it('requires verified first-contact anchors before secure bootstrap', () => {
|
||||
expect(requiresVerifiedFirstContact(undefined)).toBe(true);
|
||||
expect(hasKnownFirstContactAnchor(undefined)).toBe(false);
|
||||
expect(hasVerifiedFirstContactAnchor(undefined)).toBe(false);
|
||||
|
||||
expect(
|
||||
requiresVerifiedFirstContact({
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'anchored by signed invite',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'show_sas',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'quorum_witnessed',
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasVerifiedFirstContactAnchor({
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'anchored by signed invite',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'show_sas',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'quorum_witnessed',
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
requiresVerifiedFirstContact({
|
||||
remotePrekeyFingerprint: 'abc123',
|
||||
remotePrekeyPinnedAt: 123,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasVerifiedFirstContactAnchor({
|
||||
remotePrekeyFingerprint: 'abc123',
|
||||
remotePrekeyPinnedAt: 123,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
requiresVerifiedFirstContact({
|
||||
verified: true,
|
||||
verify_inband: true,
|
||||
verify_registry: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasVerifiedFirstContactAnchor({
|
||||
verified: true,
|
||||
verify_inband: true,
|
||||
verify_registry: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('auto-reveals SAS for trust hazards but keeps ordinary verified contacts quiet', () => {
|
||||
expect(
|
||||
shouldAutoRevealSasForTrust({
|
||||
@@ -74,20 +221,295 @@ describe('meshPrivacyHints', () => {
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAutoRevealSasForTrust({
|
||||
verified: true,
|
||||
verify_inband: true,
|
||||
verify_registry: true,
|
||||
trustSummary: {
|
||||
state: 'sas_verified',
|
||||
label: 'SAS VERIFIED',
|
||||
severity: 'good',
|
||||
detail: 'sas verified',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'show_sas',
|
||||
legacyLookup: false,
|
||||
inviteAttested: false,
|
||||
rootDistributionState: 'none',
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
dmTrustPrimaryActionLabel({
|
||||
verified: true,
|
||||
verify_inband: true,
|
||||
verify_registry: true,
|
||||
trustSummary: {
|
||||
state: 'sas_verified',
|
||||
label: 'SAS VERIFIED',
|
||||
severity: 'good',
|
||||
detail: 'sas verified',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'show_sas',
|
||||
legacyLookup: false,
|
||||
inviteAttested: false,
|
||||
rootDistributionState: 'none',
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
}),
|
||||
).toBe('SHOW SAS');
|
||||
});
|
||||
|
||||
it('maps import-invite and reverify actions to distinct labels', () => {
|
||||
expect(
|
||||
dmTrustPrimaryActionLabel({
|
||||
trustSummary: {
|
||||
state: 'unpinned',
|
||||
label: 'UNVERIFIED',
|
||||
severity: 'warn',
|
||||
detail: 'invite required',
|
||||
verifiedFirstContact: false,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: false,
|
||||
inviteAttested: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
}),
|
||||
).toBe('IMPORT INVITE');
|
||||
expect(
|
||||
dmTrustPrimaryActionLabel({
|
||||
trustSummary: {
|
||||
state: 'continuity_broken',
|
||||
label: 'CONTINUITY BROKEN',
|
||||
severity: 'danger',
|
||||
detail: 'reverify',
|
||||
verifiedFirstContact: false,
|
||||
recommendedAction: 'reverify',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
registryMismatch: true,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
}),
|
||||
).toBe('REVERIFY NOW');
|
||||
});
|
||||
|
||||
it('surfaces stable root mismatch as a continuity hazard', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'continuity_broken',
|
||||
label: 'CONTINUITY BROKEN',
|
||||
severity: 'danger',
|
||||
detail: 'root changed',
|
||||
verifiedFirstContact: false,
|
||||
recommendedAction: 'reverify',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'quorum_witnessed',
|
||||
rootMismatch: true,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'danger',
|
||||
title: 'CONTINUITY BROKEN',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('stable root identity');
|
||||
});
|
||||
|
||||
it('treats legacy lookup on an otherwise verified contact as an invite-import migration state', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'sas_verified',
|
||||
label: 'SAS VERIFIED',
|
||||
severity: 'good',
|
||||
detail: 'sas verified but still legacy lookup',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: true,
|
||||
inviteAttested: false,
|
||||
rootDistributionState: 'none',
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('IMPORT INVITE');
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'LEGACY LOOKUP',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces internal-only root continuity as an invite refresh state', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'invite pinned on internal root only',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: false,
|
||||
rootDistributionState: 'internal_only',
|
||||
rootMismatch: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('IMPORT INVITE');
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'ROOT INTERNAL ONLY',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('witnessed root');
|
||||
});
|
||||
|
||||
it('surfaces single-witness root continuity as a weaker witnessed state', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'invite pinned on single witness root',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'single_witness',
|
||||
rootWitnessCount: 1,
|
||||
rootWitnessThreshold: 1,
|
||||
rootWitnessQuorumMet: true,
|
||||
rootMismatch: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('IMPORT INVITE');
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'ROOT SINGLE WITNESS',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('quorum witness provenance');
|
||||
});
|
||||
|
||||
it('surfaces unproven witnessed root rotation as a hard invite refresh state', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'invite pinned on witnessed root without rotation proof',
|
||||
verifiedFirstContact: false,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'quorum_witnessed',
|
||||
rootManifestGeneration: 2,
|
||||
rootRotationProven: false,
|
||||
rootMismatch: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('IMPORT INVITE');
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'danger',
|
||||
title: 'ROOT ROTATION UNPROVEN',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('previous-root proof');
|
||||
});
|
||||
|
||||
it('surfaces unsatisfied witness policy as a hard invite refresh state', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'invite pinned on root missing witness quorum',
|
||||
verifiedFirstContact: false,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'witness_policy_not_met',
|
||||
rootWitnessCount: 1,
|
||||
rootWitnessThreshold: 2,
|
||||
rootWitnessQuorumMet: false,
|
||||
rootMismatch: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('IMPORT INVITE');
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'danger',
|
||||
title: 'ROOT WITNESS POLICY NOT MET',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('witness policy');
|
||||
});
|
||||
|
||||
it('transitional lane hint separates gate posture from DM posture', () => {
|
||||
const hint = buildPrivateLaneHint({
|
||||
activeTab: 'infonet',
|
||||
privateInfonetReady: true,
|
||||
privateInfonetTransportReady: false,
|
||||
});
|
||||
|
||||
expect(hint).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'TRANSITIONAL PRIVATE LANE',
|
||||
}),
|
||||
);
|
||||
// Must explicitly mention gate is on a transitional lane
|
||||
expect(hint?.detail).toContain('transitional');
|
||||
// Must explicitly mention DM requires a stronger tier
|
||||
expect(hint?.detail).toContain('Dead Drop');
|
||||
expect(hint?.detail).toMatch(/PRIVATE \/ STRONG/i);
|
||||
// Must not imply gate and DM share the same posture
|
||||
expect(hint?.detail).toContain('weaker than DM');
|
||||
});
|
||||
|
||||
it('relay delivery hint is specific to Dead Drop, not gate', () => {
|
||||
const hint = buildPrivateLaneHint({
|
||||
activeTab: 'dms',
|
||||
dmTransportMode: 'relay',
|
||||
});
|
||||
|
||||
expect(hint).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'RELAY DELIVERY ACTIVE',
|
||||
}),
|
||||
);
|
||||
expect(hint?.detail).toContain('Dead Drop');
|
||||
});
|
||||
|
||||
it('shortens long trust fingerprints for display', () => {
|
||||
expect(shortTrustFingerprint('abcdef0123456789fedcba9876543210')).toBe('abcdef01..543210');
|
||||
expect(shortTrustFingerprint('abcd1234')).toBe('abcd1234');
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
@@ -32,10 +35,59 @@ describe('mesh terminal policy', () => {
|
||||
expect(isMeshTerminalWriteCommand('send', ['broadcast', 'hello'])).toBe(true);
|
||||
});
|
||||
|
||||
it('wormhole active lock reason distinguishes gate and DM posture', () => {
|
||||
const reason = getMeshTerminalWriteLockReason({
|
||||
wormholeRequired: true,
|
||||
wormholeReady: true,
|
||||
anonymousMode: false,
|
||||
anonymousModeReady: false,
|
||||
});
|
||||
|
||||
// Must mention gate as transitional lane
|
||||
expect(reason).toContain('gate chat (transitional lane)');
|
||||
// Must mention Dead Drop as the stronger lane
|
||||
expect(reason).toContain('Dead Drop (stronger private lane)');
|
||||
// Must NOT use "hardened private actions" which flattens both
|
||||
expect(reason).not.toContain('hardened private actions');
|
||||
});
|
||||
|
||||
it('anonymous mode lock reason distinguishes gate and DM posture', () => {
|
||||
const reason = getMeshTerminalWriteLockReason({
|
||||
wormholeRequired: true,
|
||||
wormholeReady: true,
|
||||
anonymousMode: true,
|
||||
anonymousModeReady: true,
|
||||
});
|
||||
|
||||
expect(reason).toContain('gate chat (transitional lane)');
|
||||
expect(reason).toContain('Dead Drop (stronger private lane)');
|
||||
expect(reason).not.toContain('hardened');
|
||||
});
|
||||
|
||||
it('keeps read-only terminal commands available', () => {
|
||||
expect(isMeshTerminalWriteCommand('status', [])).toBe(false);
|
||||
expect(isMeshTerminalWriteCommand('signals', ['10'])).toBe(false);
|
||||
expect(isMeshTerminalWriteCommand('mesh', ['listen', '20'])).toBe(false);
|
||||
expect(isMeshTerminalWriteCommand('messages', [])).toBe(false);
|
||||
});
|
||||
|
||||
it('MeshTerminal does not use raw agent-id fetch as the ordinary DM send path', () => {
|
||||
const terminal = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../components/MeshTerminal.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(terminal).toContain('fetchDmPublicKey');
|
||||
expect(terminal).toContain("only for legacy migration");
|
||||
expect(terminal).not.toContain('/api/mesh/dm/pubkey?agent_id=');
|
||||
});
|
||||
|
||||
it('MeshTerminal inbox surface owns mailbox refresh instead of racing the unread poll loop', () => {
|
||||
const terminal = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../components/MeshTerminal.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(terminal).toContain("if (!isOpen || !nodeIdentity || !hasSovereignty() || !getDMNotify() || surfacePanel === 'inbox') return;");
|
||||
expect(terminal).toContain('classifyTick(hasMore, catchUpBudget, 15_000)');
|
||||
expect(terminal).toContain('() => void loadInboxSurface(classification.refreshCount)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
let contactsState: Record<string, any> = {};
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
buildMailboxClaims: vi.fn(async () => []),
|
||||
countDmMailboxes: vi.fn(async () => ({ ok: true, count: 0 })),
|
||||
ensureRegisteredDmKey: vi.fn(async () => ({ dhPubKey: 'local-dh', dhAlgo: 'X25519' })),
|
||||
fetchDmPublicKey: vi.fn(async () => ({ dh_pub_key: 'peer-dh', dh_algo: 'X25519' })),
|
||||
pollDmMailboxes: vi.fn(async () => ({ ok: true, messages: [] })),
|
||||
sendDmMessage: vi.fn(async () => ({ ok: true, transport: 'relay' })),
|
||||
sendOffLedgerConsentMessage: vi.fn(async () => ({ ok: true, transport: 'relay' })),
|
||||
sharedMailboxToken: vi.fn(async () => 'shared-token'),
|
||||
buildContactAcceptMessage: vi.fn(() => 'accept'),
|
||||
buildContactDenyMessage: vi.fn(() => 'deny'),
|
||||
buildContactOfferMessage: vi.fn(() => 'offer'),
|
||||
generateSharedAlias: vi.fn(() => 'alias-123'),
|
||||
mergeAliasHistory: vi.fn((history?: string[]) => history || []),
|
||||
parseAliasRotateMessage: vi.fn(() => null),
|
||||
parseDmConsentMessage: vi.fn(() => null),
|
||||
preferredDmPeerId: vi.fn((peerId: string) => peerId),
|
||||
allDmPeerIds: vi.fn(() => []),
|
||||
purgeBrowserDmState: vi.fn(async () => {}),
|
||||
ratchetDecryptDM: vi.fn(async () => {
|
||||
throw new Error('no_ratchet_state');
|
||||
}),
|
||||
ratchetEncryptDM: vi.fn(async () => 'ratchet-ciphertext'),
|
||||
addContact: vi.fn(),
|
||||
blockContact: vi.fn(),
|
||||
decryptDM: vi.fn(async () => 'plaintext'),
|
||||
decryptSenderSealPayloadLocally: vi.fn(async () => ''),
|
||||
deriveSharedKey: vi.fn(async () => ({})),
|
||||
encryptDM: vi.fn(async () => 'ciphertext'),
|
||||
getContacts: vi.fn(() => contactsState),
|
||||
getDHAlgo: vi.fn(() => 'X25519'),
|
||||
getNodeIdentity: vi.fn(() => ({
|
||||
nodeId: '!sb_local',
|
||||
publicKey: 'local-pub',
|
||||
privateKey: 'local-priv',
|
||||
})),
|
||||
hasSovereignty: vi.fn(() => true),
|
||||
hydrateWormholeContacts: vi.fn(async () => contactsState),
|
||||
purgeBrowserContactGraph: vi.fn(),
|
||||
purgeBrowserSigningMaterial: vi.fn(),
|
||||
removeContact: vi.fn(),
|
||||
unblockContact: vi.fn(),
|
||||
unwrapSenderSealPayload: vi.fn(() => ({ version: 'v2', ephemeralPub: '' })),
|
||||
updateContact: vi.fn(),
|
||||
verifyNodeIdBindingFromPublicKey: vi.fn(async () => true),
|
||||
verifyRawSignature: vi.fn(async () => true),
|
||||
getSenderRecoveryState: vi.fn(() => 'verified'),
|
||||
recoverSenderSealWithFallback: vi.fn(async () => null),
|
||||
requiresSenderRecovery: vi.fn(() => false),
|
||||
shouldKeepUnresolvedRequestVisible: vi.fn(() => false),
|
||||
shouldPromoteRecoveredSenderForBootstrap: vi.fn(() => false),
|
||||
shouldPromoteRecoveredSenderForKnownContact: vi.fn(() => false),
|
||||
bootstrapDecryptAccessRequest: vi.fn(async () => 'offer'),
|
||||
bootstrapEncryptAccessRequest: vi.fn(async () => 'x3dh1:bootstrap'),
|
||||
canUseWormholeBootstrap: vi.fn(async () => false),
|
||||
fetchWormholeStatus: vi.fn(async () => ({ ready: true, transport_tier: 'private_strong' })),
|
||||
fetchWormholeIdentity: vi.fn(async () => ({ node_id: '!sb_local', public_key: 'local-pub' })),
|
||||
prepareWormholeInteractiveLane: vi.fn(async () => ({
|
||||
ready: true,
|
||||
settingsEnabled: true,
|
||||
transportTier: 'private_transitional',
|
||||
identity: { node_id: '!sb_local', public_key: 'local-pub' },
|
||||
})),
|
||||
importWormholeDmInvite: vi.fn(async () => ({
|
||||
ok: true,
|
||||
peer_id: '!sb_imported',
|
||||
trust_fingerprint: 'invitefp',
|
||||
trust_level: 'invite_pinned',
|
||||
})),
|
||||
isWormholeReady: vi.fn(async () => true),
|
||||
isWormholeSecureRequired: vi.fn(async () => false),
|
||||
issueWormholePairwiseAlias: vi.fn(async () => ({ ok: true, shared_alias: 'alias-123' })),
|
||||
openWormholeSenderSeal: vi.fn(async () => ({ sender_id: '!sb_peer', seal_verified: true })),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
API_BASE: 'http://localhost:8000',
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshDmClient', () => ({
|
||||
buildMailboxClaims: mocks.buildMailboxClaims,
|
||||
countDmMailboxes: mocks.countDmMailboxes,
|
||||
ensureRegisteredDmKey: mocks.ensureRegisteredDmKey,
|
||||
fetchDmPublicKey: mocks.fetchDmPublicKey,
|
||||
pollDmMailboxes: mocks.pollDmMailboxes,
|
||||
sendDmMessage: mocks.sendDmMessage,
|
||||
sendOffLedgerConsentMessage: mocks.sendOffLedgerConsentMessage,
|
||||
sharedMailboxToken: mocks.sharedMailboxToken,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshDmConsent', () => ({
|
||||
allDmPeerIds: mocks.allDmPeerIds,
|
||||
buildContactAcceptMessage: mocks.buildContactAcceptMessage,
|
||||
buildContactDenyMessage: mocks.buildContactDenyMessage,
|
||||
buildContactOfferMessage: mocks.buildContactOfferMessage,
|
||||
generateSharedAlias: mocks.generateSharedAlias,
|
||||
mergeAliasHistory: mocks.mergeAliasHistory,
|
||||
parseAliasRotateMessage: mocks.parseAliasRotateMessage,
|
||||
parseDmConsentMessage: mocks.parseDmConsentMessage,
|
||||
preferredDmPeerId: mocks.preferredDmPeerId,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshDmWorkerClient', () => ({
|
||||
purgeBrowserDmState: mocks.purgeBrowserDmState,
|
||||
ratchetDecryptDM: mocks.ratchetDecryptDM,
|
||||
ratchetEncryptDM: mocks.ratchetEncryptDM,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
addContact: mocks.addContact,
|
||||
blockContact: mocks.blockContact,
|
||||
decryptDM: mocks.decryptDM,
|
||||
decryptSenderSealPayloadLocally: mocks.decryptSenderSealPayloadLocally,
|
||||
deriveSharedKey: mocks.deriveSharedKey,
|
||||
encryptDM: mocks.encryptDM,
|
||||
getContacts: mocks.getContacts,
|
||||
getDHAlgo: mocks.getDHAlgo,
|
||||
getNodeIdentity: mocks.getNodeIdentity,
|
||||
hasSovereignty: mocks.hasSovereignty,
|
||||
hydrateWormholeContacts: mocks.hydrateWormholeContacts,
|
||||
purgeBrowserContactGraph: mocks.purgeBrowserContactGraph,
|
||||
purgeBrowserSigningMaterial: mocks.purgeBrowserSigningMaterial,
|
||||
removeContact: mocks.removeContact,
|
||||
unblockContact: mocks.unblockContact,
|
||||
unwrapSenderSealPayload: mocks.unwrapSenderSealPayload,
|
||||
updateContact: mocks.updateContact,
|
||||
verifyNodeIdBindingFromPublicKey: mocks.verifyNodeIdBindingFromPublicKey,
|
||||
verifyRawSignature: mocks.verifyRawSignature,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/requestSenderRecovery', () => ({
|
||||
getSenderRecoveryState: mocks.getSenderRecoveryState,
|
||||
recoverSenderSealWithFallback: mocks.recoverSenderSealWithFallback,
|
||||
requiresSenderRecovery: mocks.requiresSenderRecovery,
|
||||
shouldKeepUnresolvedRequestVisible: mocks.shouldKeepUnresolvedRequestVisible,
|
||||
shouldPromoteRecoveredSenderForBootstrap: mocks.shouldPromoteRecoveredSenderForBootstrap,
|
||||
shouldPromoteRecoveredSenderForKnownContact: mocks.shouldPromoteRecoveredSenderForKnownContact,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeDmBootstrapClient', () => ({
|
||||
bootstrapDecryptAccessRequest: mocks.bootstrapDecryptAccessRequest,
|
||||
bootstrapEncryptAccessRequest: mocks.bootstrapEncryptAccessRequest,
|
||||
canUseWormholeBootstrap: mocks.canUseWormholeBootstrap,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
fetchWormholeStatus: mocks.fetchWormholeStatus,
|
||||
fetchWormholeIdentity: mocks.fetchWormholeIdentity,
|
||||
prepareWormholeInteractiveLane: mocks.prepareWormholeInteractiveLane,
|
||||
getWormholeDmInviteImportErrorResult: (error: unknown) =>
|
||||
error && typeof error === 'object' && 'result' in (error as Record<string, unknown>)
|
||||
? (((error as Record<string, unknown>).result as Record<string, unknown>) || null)
|
||||
: null,
|
||||
importWormholeDmInvite: mocks.importWormholeDmInvite,
|
||||
isWormholeReady: mocks.isWormholeReady,
|
||||
isWormholeSecureRequired: mocks.isWormholeSecureRequired,
|
||||
issueWormholePairwiseAlias: mocks.issueWormholePairwiseAlias,
|
||||
openWormholeSenderSeal: mocks.openWormholeSenderSeal,
|
||||
}));
|
||||
|
||||
import MessagesView from '@/components/InfonetTerminal/MessagesView';
|
||||
|
||||
function renderMessagesView(options?: {
|
||||
onOpenDeadDrop?: (peerId: string, opts?: { showSas?: boolean }) => void;
|
||||
}) {
|
||||
return render(<MessagesView onBack={() => {}} onOpenDeadDrop={options?.onOpenDeadDrop} />);
|
||||
}
|
||||
|
||||
async function openComposeForRecipient(recipient: string, body: string) {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'COMPOSE' }));
|
||||
fireEvent.change(screen.getByLabelText(/Recipient agent ID/i), {
|
||||
target: { value: recipient },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Message/i), {
|
||||
target: { value: body },
|
||||
});
|
||||
await screen.findByLabelText(/Recipient agent ID/i);
|
||||
}
|
||||
|
||||
describe('MessagesView first-contact trust UX', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
contactsState = {};
|
||||
vi.clearAllMocks();
|
||||
|
||||
mocks.getContacts.mockImplementation(() => contactsState);
|
||||
mocks.hydrateWormholeContacts.mockImplementation(async () => contactsState);
|
||||
mocks.fetchWormholeStatus.mockResolvedValue({ ready: true, transport_tier: 'private_strong' });
|
||||
mocks.prepareWormholeInteractiveLane.mockResolvedValue({
|
||||
ready: true,
|
||||
settingsEnabled: true,
|
||||
transportTier: 'private_transitional',
|
||||
identity: { node_id: '!sb_local', public_key: 'local-pub' },
|
||||
});
|
||||
mocks.isWormholeSecureRequired.mockResolvedValue(false);
|
||||
mocks.getNodeIdentity.mockReturnValue({
|
||||
nodeId: '!sb_local',
|
||||
publicKey: 'local-pub',
|
||||
privateKey: 'local-priv',
|
||||
});
|
||||
mocks.hasSovereignty.mockReturnValue(true);
|
||||
mocks.buildMailboxClaims.mockResolvedValue([]);
|
||||
mocks.pollDmMailboxes.mockResolvedValue({ ok: true, messages: [] });
|
||||
mocks.countDmMailboxes.mockResolvedValue({ ok: true, count: 0 });
|
||||
mocks.ensureRegisteredDmKey.mockResolvedValue({ dhPubKey: 'local-dh', dhAlgo: 'X25519' });
|
||||
mocks.fetchDmPublicKey.mockResolvedValue({ dh_pub_key: 'peer-dh', dh_algo: 'X25519' });
|
||||
mocks.sendOffLedgerConsentMessage.mockResolvedValue({ ok: true, transport: 'relay' });
|
||||
mocks.canUseWormholeBootstrap.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('blocks unknown first contact until a signed invite is imported', async () => {
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_unknown', 'hello from first contact');
|
||||
|
||||
expect(await screen.findByText('Verified First Contact Required')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Secure request bootstrap is blocked until you import a signed invite/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Send Secure Mail' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('can jump directly from the downgrade warning into invite import flow', async () => {
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_unknown', 'hello from first contact');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Signed Invite' }));
|
||||
|
||||
expect(await screen.findByText('Import Verified Invite')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Local Alias/i)).toHaveValue('!sb_unknown');
|
||||
});
|
||||
|
||||
it('does not expose a TOFU downgrade button for first contact anymore', async () => {
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_unknown', 'hello from first contact');
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Explicitly Allow TOFU/i })).not.toBeInTheDocument();
|
||||
expect(mocks.sendOffLedgerConsentMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not require the TOFU override when the contact is invite-pinned already', async () => {
|
||||
contactsState = {
|
||||
'!sb_invited': {
|
||||
alias: 'Pinned Peer',
|
||||
blocked: false,
|
||||
trust_level: 'invite_pinned',
|
||||
invitePinnedTrustFingerprint: 'abcdef123456',
|
||||
invitePinnedRootFingerprint: 'rootabcdef123456',
|
||||
invitePinnedRootManifestFingerprint: 'manifestabcdef123456',
|
||||
invitePinnedRootWitnessPolicyFingerprint: 'policyabcdef123456',
|
||||
invitePinnedRootWitnessThreshold: 2,
|
||||
invitePinnedRootWitnessCount: 3,
|
||||
invitePinnedRootManifestGeneration: 1,
|
||||
invitePinnedRootRotationProven: true,
|
||||
invitePinnedAt: 123,
|
||||
remotePrekeyFingerprint: 'abcdef123456',
|
||||
remotePrekeyRootFingerprint: 'rootabcdef123456',
|
||||
remotePrekeyRootManifestFingerprint: 'manifestabcdef123456',
|
||||
remotePrekeyRootWitnessPolicyFingerprint: 'policyabcdef123456',
|
||||
remotePrekeyRootWitnessThreshold: 2,
|
||||
remotePrekeyRootWitnessCount: 3,
|
||||
remotePrekeyRootManifestGeneration: 1,
|
||||
remotePrekeyRootRotationProven: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_invited', 'hello to pinned peer');
|
||||
|
||||
expect(screen.queryByText('Unverified First Contact')).not.toBeInTheDocument();
|
||||
expect(await screen.findByText('ROOT LOCAL QUORUM')).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Local quorum root rootabcd\.\.123456/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Send Secure Mail' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('warms the private lane in the background before sending secure mail', async () => {
|
||||
contactsState = {
|
||||
'!sb_pinned': {
|
||||
alias: 'Pinned Peer',
|
||||
blocked: false,
|
||||
trust_level: 'invite_pinned',
|
||||
dhPubKey: 'peer-dh',
|
||||
remotePrekeyFingerprint: 'abcdef123456',
|
||||
},
|
||||
};
|
||||
mocks.fetchWormholeStatus.mockResolvedValue({ ready: false, transport_tier: 'public_degraded' });
|
||||
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_pinned', 'hello after warmup');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send Secure Mail' }));
|
||||
|
||||
await screen.findByText(/Mail delivered to Pinned Peer/i);
|
||||
expect(mocks.prepareWormholeInteractiveLane).toHaveBeenCalled();
|
||||
expect(mocks.sendDmMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not flatten witness policy not met into a generic witnessed root label', async () => {
|
||||
contactsState = {
|
||||
'!sb_policy': {
|
||||
alias: 'Policy Peer',
|
||||
blocked: false,
|
||||
trust_level: 'invite_pinned',
|
||||
invitePinnedTrustFingerprint: 'policyfingerprint123456',
|
||||
invitePinnedRootFingerprint: 'rootpolicyabcdef123456',
|
||||
invitePinnedRootManifestFingerprint: 'manifestpolicyabcdef123456',
|
||||
invitePinnedRootWitnessPolicyFingerprint: 'policyabcdef123456',
|
||||
invitePinnedRootWitnessThreshold: 2,
|
||||
invitePinnedRootWitnessCount: 1,
|
||||
invitePinnedRootManifestGeneration: 1,
|
||||
invitePinnedRootRotationProven: true,
|
||||
invitePinnedAt: 123,
|
||||
remotePrekeyFingerprint: 'policyfingerprint123456',
|
||||
remotePrekeyRootFingerprint: 'rootpolicyabcdef123456',
|
||||
remotePrekeyRootManifestFingerprint: 'manifestpolicyabcdef123456',
|
||||
remotePrekeyRootWitnessPolicyFingerprint: 'policyabcdef123456',
|
||||
remotePrekeyRootWitnessThreshold: 2,
|
||||
remotePrekeyRootWitnessCount: 1,
|
||||
remotePrekeyRootManifestGeneration: 1,
|
||||
remotePrekeyRootRotationProven: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
|
||||
expect(await screen.findByText(/Witness-policy root rootpoli\.\.123456/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Witnessed root rootpoli\.\.123456/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an import-invite shortcut for unpinned contacts in the contact list', async () => {
|
||||
contactsState = {
|
||||
'!sb_unpinned': {
|
||||
alias: 'Weak Peer',
|
||||
blocked: false,
|
||||
dhPubKey: 'peer-dh',
|
||||
trust_level: 'unpinned',
|
||||
},
|
||||
};
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
|
||||
const importButton = await screen.findByRole('button', { name: 'Import Invite' });
|
||||
fireEvent.click(importButton);
|
||||
expect(screen.getByLabelText(/Local Alias/i)).toHaveValue('!sb_unpinned');
|
||||
});
|
||||
|
||||
it('routes continuity reverify from Secure Messages into Dead Drop with SAS visible', async () => {
|
||||
contactsState = {
|
||||
'!sb_reverify': {
|
||||
alias: 'Broken Root Peer',
|
||||
blocked: false,
|
||||
trust_level: 'continuity_broken',
|
||||
remotePrekeyObservedFingerprint: 'observed123456',
|
||||
remotePrekeyObservedRootFingerprint: 'rootobserved123456',
|
||||
remotePrekeyRootMismatch: true,
|
||||
},
|
||||
};
|
||||
const onOpenDeadDrop = vi.fn();
|
||||
|
||||
renderMessagesView({ onOpenDeadDrop });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
|
||||
const reverifyButton = await screen.findByRole('button', { name: 'REVERIFY NOW' });
|
||||
fireEvent.click(reverifyButton);
|
||||
|
||||
expect(onOpenDeadDrop).toHaveBeenCalledWith('!sb_reverify', { showSas: true });
|
||||
});
|
||||
|
||||
it('still blocks first contact when legacy verified flags and a dh key are seeded on an unpinned contact', async () => {
|
||||
contactsState = {
|
||||
'!sb_seeded': {
|
||||
alias: 'Seeded Peer',
|
||||
blocked: false,
|
||||
dhPubKey: 'forged-dh',
|
||||
verify_inband: true,
|
||||
verify_registry: true,
|
||||
verified: true,
|
||||
trust_level: 'unpinned',
|
||||
trustSummary: {
|
||||
state: 'unpinned',
|
||||
label: 'UNVERIFIED',
|
||||
severity: 'warn',
|
||||
detail: 'invite required',
|
||||
verifiedFirstContact: false,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: false,
|
||||
inviteAttested: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_seeded', 'hello from forged first contact');
|
||||
|
||||
expect(await screen.findByText('Verified First Contact Required')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Secure request bootstrap is blocked until you import a signed invite/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Send Secure Mail' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('blocks ambient legacy lookup for verified contacts that still lack an invite handle', async () => {
|
||||
contactsState = {
|
||||
'!sb_legacy': {
|
||||
alias: 'Legacy Peer',
|
||||
blocked: false,
|
||||
trust_level: 'sas_verified',
|
||||
remotePrekeyLookupMode: 'legacy_agent_id',
|
||||
trustSummary: {
|
||||
state: 'sas_verified',
|
||||
label: 'SAS VERIFIED',
|
||||
severity: 'good',
|
||||
detail: 'legacy lookup still active',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: true,
|
||||
inviteAttested: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_legacy', 'hello from a legacy lookup contact');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send Secure Mail' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/Import or re-import a signed invite before sending a contact request; legacy direct lookup is disabled\./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(mocks.fetchDmPublicKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('announces attested invite imports as INVITE PINNED', async () => {
|
||||
mocks.importWormholeDmInvite.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
peer_id: '!sb_attested',
|
||||
trust_fingerprint: 'invitefp-attested',
|
||||
trust_level: 'invite_pinned',
|
||||
contact: {},
|
||||
});
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
expect(await screen.findByText('Import Verified Invite')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Signed Invite JSON/i), {
|
||||
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Signed Invite' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/INVITE PINNED for !sb_attested \(invitefp\.\.tested\)\./i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('announces compat invite imports as TOFU PINNED with backend detail', async () => {
|
||||
mocks.importWormholeDmInvite.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
peer_id: '!sb_compat',
|
||||
trust_fingerprint: 'invitefp-compat',
|
||||
trust_level: 'tofu_pinned',
|
||||
detail: 'legacy invite imported as tofu_pinned; SAS verification required before first contact',
|
||||
contact: {},
|
||||
});
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
expect(await screen.findByText('Import Verified Invite')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Signed Invite JSON/i), {
|
||||
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Signed Invite' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/TOFU PINNED for !sb_compat \(invitefp\.\.compat\)\./i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/legacy invite imported as tofu_pinned; SAS verification required before first contact/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces stable root continuity breaks on invite re-import', async () => {
|
||||
contactsState = {
|
||||
'!sb_attested': {
|
||||
alias: 'Pinned Peer',
|
||||
blocked: false,
|
||||
trust_level: 'continuity_broken',
|
||||
invitePinnedTrustFingerprint: 'oldfingerprint123456',
|
||||
invitePinnedRootFingerprint: 'rootold123456',
|
||||
remotePrekeyFingerprint: 'newfingerprint654321',
|
||||
remotePrekeyObservedFingerprint: 'newfingerprint654321',
|
||||
remotePrekeyRootFingerprint: 'rootold123456',
|
||||
remotePrekeyObservedRootFingerprint: 'rootnew654321',
|
||||
remotePrekeyRootMismatch: true,
|
||||
},
|
||||
};
|
||||
const error = Object.assign(
|
||||
new Error(
|
||||
'signed invite root continuity mismatch; re-verify SAS or replace the signed invite before trusting this root change',
|
||||
),
|
||||
{
|
||||
result: {
|
||||
ok: false,
|
||||
peer_id: '!sb_attested',
|
||||
trust_level: 'continuity_broken',
|
||||
detail:
|
||||
'signed invite root continuity mismatch; re-verify SAS or replace the signed invite before trusting this root change',
|
||||
contact: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
mocks.importWormholeDmInvite.mockRejectedValueOnce(error);
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
expect(await screen.findByText('Import Verified Invite')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Signed Invite JSON/i), {
|
||||
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Signed Invite' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/CONTINUITY BROKEN for Pinned Peer\. Stable root continuity changed\./i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/re-verify SAS in Dead Drop or replace the signed invite before trusting this contact again/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses non-blocking secure-mail startup language while the DM lane warms', async () => {
|
||||
mocks.fetchWormholeStatus.mockResolvedValueOnce({ ready: false, transport_tier: 'public_degraded' });
|
||||
mocks.prepareWormholeInteractiveLane.mockImplementation(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
/* keep background warm-up pending for this assertion */
|
||||
}),
|
||||
);
|
||||
|
||||
renderMessagesView();
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Preparing secure mail in the background/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/LOCKED/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/enter the Wormhole/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import React from 'react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const deferred = <T,>() => {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const fetchWormholeStatus = vi.fn(async () => ({
|
||||
ready: false,
|
||||
running: false,
|
||||
transport_tier: 'public_degraded',
|
||||
transport_active: 'public_degraded',
|
||||
}));
|
||||
const prepareWormholeInteractiveLane = vi.fn();
|
||||
const fetchWormholeSettings = vi.fn(async () => ({
|
||||
enabled: false,
|
||||
anonymous_mode: false,
|
||||
}));
|
||||
const purgeBrowserContactGraph = vi.fn();
|
||||
const purgeBrowserSigningMaterial = vi.fn();
|
||||
const setSecureModeCached = vi.fn();
|
||||
const getNodeIdentity = vi.fn(() => null);
|
||||
const generateNodeKeys = vi.fn(async () => ({}));
|
||||
const purgeBrowserDmState = vi.fn(async () => {});
|
||||
const fetchInfonetNodeStatusSnapshot = vi.fn(async () => ({
|
||||
enabled: false,
|
||||
peers_ready: false,
|
||||
identity_ready: false,
|
||||
}));
|
||||
const requestMeshTerminalOpen = vi.fn();
|
||||
const subscribeSecureMeshTerminalLauncherOpen = vi.fn(() => () => {});
|
||||
const classifyUpdateRuntime = vi.fn(() => ({
|
||||
action: 'auto_apply',
|
||||
detail: 'test',
|
||||
}));
|
||||
const getDesktopUpdateContext = vi.fn(() => ({
|
||||
packaged: false,
|
||||
ownsLocalBackend: false,
|
||||
}));
|
||||
const getPreferredManualUpdateUrl = vi.fn(() => 'https://example.test/releases/latest');
|
||||
const getUpdateAction = vi.fn(() => 'auto_apply');
|
||||
const controlPlaneFetch = vi.fn();
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
fetchWormholeStatus,
|
||||
prepareWormholeInteractiveLane,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeClient', () => ({
|
||||
fetchWormholeSettings,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
purgeBrowserContactGraph,
|
||||
purgeBrowserSigningMaterial,
|
||||
setSecureModeCached,
|
||||
getNodeIdentity,
|
||||
generateNodeKeys,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshDmWorkerClient', () => ({
|
||||
purgeBrowserDmState,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/controlPlaneStatusClient', () => ({
|
||||
fetchInfonetNodeStatusSnapshot,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/meshTerminalLauncher', () => ({
|
||||
requestMeshTerminalOpen,
|
||||
subscribeSecureMeshTerminalLauncherOpen,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/updateRuntime', () => ({
|
||||
classifyUpdateRuntime,
|
||||
getDesktopUpdateContext,
|
||||
getPreferredManualUpdateUrl,
|
||||
getUpdateAction,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneFetch,
|
||||
}));
|
||||
|
||||
describe('TopRightControls terminal launcher', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchWormholeStatus.mockResolvedValue({
|
||||
ready: false,
|
||||
running: false,
|
||||
transport_tier: 'public_degraded',
|
||||
transport_active: 'public_degraded',
|
||||
});
|
||||
fetchWormholeSettings.mockResolvedValue({
|
||||
enabled: false,
|
||||
anonymous_mode: false,
|
||||
});
|
||||
fetchInfonetNodeStatusSnapshot.mockResolvedValue({
|
||||
enabled: false,
|
||||
peers_ready: false,
|
||||
identity_ready: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('opens the terminal immediately while Wormhole prep continues in the background', async () => {
|
||||
const prep = deferred<{
|
||||
ready: boolean;
|
||||
settingsEnabled: boolean;
|
||||
transportTier: string;
|
||||
identity: null;
|
||||
}>();
|
||||
prepareWormholeInteractiveLane.mockReturnValue(prep.promise);
|
||||
|
||||
const { default: TopRightControls } = await import('@/components/TopRightControls');
|
||||
const onTerminalToggle = vi.fn();
|
||||
|
||||
render(<TopRightControls onTerminalToggle={onTerminalToggle} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /terminal/i }));
|
||||
expect(await screen.findByRole('button', { name: /activate wormhole/i })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /activate wormhole/i }));
|
||||
|
||||
await waitFor(() => expect(onTerminalToggle).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('button', { name: /activate wormhole/i })).toBeNull(),
|
||||
);
|
||||
expect(prepareWormholeInteractiveLane).toHaveBeenCalledWith({ bootstrapIdentity: true });
|
||||
|
||||
prep.resolve({
|
||||
ready: true,
|
||||
settingsEnabled: true,
|
||||
transportTier: 'private_control_only',
|
||||
identity: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatLegacyCompatibilitySeenAt,
|
||||
hasLegacyCompatibilityActivity,
|
||||
summarizeLegacyCompatibility,
|
||||
type LegacyCompatibilitySnapshot,
|
||||
} from '@/mesh/wormholeCompatibility';
|
||||
|
||||
describe('wormholeCompatibility helpers', () => {
|
||||
it('summarizes empty snapshots with zeroed metrics', () => {
|
||||
const items = summarizeLegacyCompatibility(undefined);
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toMatchObject({
|
||||
key: 'legacy_node_id_binding',
|
||||
blocked: false,
|
||||
count: 0,
|
||||
blockedCount: 0,
|
||||
targetVersion: 'n/a',
|
||||
targetDate: 'n/a',
|
||||
recentTargets: [],
|
||||
});
|
||||
expect(items[1]).toMatchObject({
|
||||
key: 'legacy_agent_id_lookup',
|
||||
blocked: false,
|
||||
count: 0,
|
||||
blockedCount: 0,
|
||||
targetVersion: 'n/a',
|
||||
targetDate: 'n/a',
|
||||
recentTargets: [],
|
||||
});
|
||||
expect(hasLegacyCompatibilityActivity(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('formats legacy usage, block state, and recent targets', () => {
|
||||
const snapshot: LegacyCompatibilitySnapshot = {
|
||||
sunset: {
|
||||
legacy_node_id_binding: {
|
||||
target_version: '0.10.0',
|
||||
target_date: '2026-06-01',
|
||||
blocked: true,
|
||||
},
|
||||
legacy_agent_id_lookup: {
|
||||
target_version: '0.10.0',
|
||||
target_date: '2026-06-01',
|
||||
blocked: false,
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
legacy_node_id_binding: {
|
||||
count: 4,
|
||||
blocked_count: 2,
|
||||
last_seen_at: 1712345678,
|
||||
recent_targets: [
|
||||
{
|
||||
node_id: 'abcdef0123456789',
|
||||
current_node_id: 'fedcba9876543210abcdef0123456789',
|
||||
},
|
||||
],
|
||||
},
|
||||
legacy_agent_id_lookup: {
|
||||
count: 3,
|
||||
blocked_count: 1,
|
||||
last_seen_at: 1712345000,
|
||||
recent_targets: [
|
||||
{
|
||||
agent_id: 'agent-xyz-0123456789',
|
||||
lookup_kinds: ['prekey_bundle', 'dh_pubkey'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const items = summarizeLegacyCompatibility(snapshot);
|
||||
|
||||
expect(items[0]).toMatchObject({
|
||||
blocked: true,
|
||||
count: 4,
|
||||
blockedCount: 2,
|
||||
targetVersion: '0.10.0',
|
||||
targetDate: '2026-06-01',
|
||||
});
|
||||
expect(items[0].recentTargets[0]).toContain('abcdef0123...');
|
||||
expect(items[0].recentTargets[0]).toContain('fedcba9876...');
|
||||
expect(items[1]).toMatchObject({
|
||||
blocked: false,
|
||||
count: 3,
|
||||
blockedCount: 1,
|
||||
targetVersion: '0.10.0',
|
||||
targetDate: '2026-06-01',
|
||||
});
|
||||
expect(items[1].recentTargets[0]).toContain('agent-xyz-...');
|
||||
expect(items[1].recentTargets[0]).toContain('prekey_bundle, dh_pubkey');
|
||||
expect(hasLegacyCompatibilityActivity(snapshot)).toBe(true);
|
||||
});
|
||||
|
||||
it('formats seen timestamps as stable UTC text', () => {
|
||||
expect(formatLegacyCompatibilitySeenAt(0)).toBe('never');
|
||||
expect(formatLegacyCompatibilitySeenAt(1712345678)).toBe('2024-04-05 19:34Z');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Sprint 4D behavioral tests — page.tsx wormhole teardown and layer sync.
|
||||
*
|
||||
* These tests exercise actual runtime logic:
|
||||
* 1. teardownWormholeOnClose — calls leaveWormhole only when state is ready or running
|
||||
* 2. Layer sync first-mount suppression — initial sync does NOT dispatch LAYER_TOGGLE_EVENT
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { teardownWormholeOnClose } from '@/lib/wormholeTeardown';
|
||||
import { LAYER_TOGGLE_EVENT } from '@/hooks/useDataPolling';
|
||||
|
||||
// ─── teardownWormholeOnClose ──────────────────────────────────────────────
|
||||
|
||||
describe('page.tsx behavior — teardownWormholeOnClose', () => {
|
||||
let fetchState: ReturnType<typeof vi.fn>;
|
||||
let leave: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchState = vi.fn();
|
||||
leave = vi.fn().mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('calls leaveWormhole when state is ready', async () => {
|
||||
fetchState.mockResolvedValue({ ready: true, running: false });
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(fetchState).toHaveBeenCalledWith(false);
|
||||
expect(leave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls leaveWormhole when state is running', async () => {
|
||||
fetchState.mockResolvedValue({ ready: false, running: true });
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(leave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls leaveWormhole when state is both ready and running', async () => {
|
||||
fetchState.mockResolvedValue({ ready: true, running: true });
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(leave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does NOT call leaveWormhole when state is neither ready nor running', async () => {
|
||||
fetchState.mockResolvedValue({ ready: false, running: false });
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(fetchState).toHaveBeenCalledWith(false);
|
||||
expect(leave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT call leaveWormhole when state is null', async () => {
|
||||
fetchState.mockResolvedValue(null);
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(leave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows fetchState errors gracefully', async () => {
|
||||
fetchState.mockRejectedValue(new Error('network down'));
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(leave).not.toHaveBeenCalled();
|
||||
// No error thrown — handler is best-effort
|
||||
});
|
||||
|
||||
it('swallows leaveWormhole errors gracefully', async () => {
|
||||
fetchState.mockResolvedValue({ ready: true });
|
||||
leave.mockRejectedValue(new Error('leave failed'));
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
// No error thrown — handler is best-effort
|
||||
});
|
||||
|
||||
it('always passes force=false to fetchState', async () => {
|
||||
fetchState.mockResolvedValue({ ready: true });
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(fetchState).toHaveBeenCalledWith(false);
|
||||
expect(fetchState).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Layer sync first-mount suppression ───────────────────────────────────
|
||||
|
||||
describe('page.tsx behavior — layer sync first-mount suppression', () => {
|
||||
it('LAYER_TOGGLE_EVENT is the expected string constant', () => {
|
||||
expect(LAYER_TOGGLE_EVENT).toBe('sb:layer-toggle');
|
||||
});
|
||||
|
||||
it('first-mount ref pattern suppresses dispatch, subsequent calls dispatch', () => {
|
||||
// Simulate the initialLayerSyncRef pattern from page.tsx
|
||||
const initialSyncDone = { current: false };
|
||||
const dispatched: boolean[] = [];
|
||||
|
||||
const syncLayers = (triggerRefetch: boolean) => {
|
||||
if (triggerRefetch) {
|
||||
dispatched.push(true);
|
||||
} else {
|
||||
dispatched.push(false);
|
||||
}
|
||||
};
|
||||
|
||||
// First call (mount): should pass false → no dispatch
|
||||
if (!initialSyncDone.current) {
|
||||
initialSyncDone.current = true;
|
||||
syncLayers(false);
|
||||
} else {
|
||||
syncLayers(true);
|
||||
}
|
||||
expect(dispatched).toEqual([false]);
|
||||
|
||||
// Second call (layer change): should pass true → dispatch
|
||||
if (!initialSyncDone.current) {
|
||||
initialSyncDone.current = true;
|
||||
syncLayers(false);
|
||||
} else {
|
||||
syncLayers(true);
|
||||
}
|
||||
expect(dispatched).toEqual([false, true]);
|
||||
|
||||
// Third call (another layer change): should still dispatch
|
||||
if (!initialSyncDone.current) {
|
||||
initialSyncDone.current = true;
|
||||
syncLayers(false);
|
||||
} else {
|
||||
syncLayers(true);
|
||||
}
|
||||
expect(dispatched).toEqual([false, true, true]);
|
||||
});
|
||||
|
||||
it('page.tsx uses initialLayerSyncRef for first-mount suppression', () => {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const page = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../app/page.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(page).toContain('initialLayerSyncRef');
|
||||
expect(page).toContain('void syncLayers(false)');
|
||||
expect(page).toContain('void syncLayers(true)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Sprint 4B regression tests — page.tsx decomposition boundary checks.
|
||||
*
|
||||
* These tests validate the frozen contract for page.tsx decomposition:
|
||||
* 1. InfonetTerminal onClose still calls leaveWormhole when wormhole is ready/running
|
||||
* 2. Initial /api/layers sync does NOT dispatch LAYER_TOGGLE_EVENT on first mount
|
||||
* 3. launchMeshChatTab preserves atomic leftOpen + leftMeshExpanded + meshChatLaunchRequest
|
||||
* 4. LocateBar extracted to page-local module
|
||||
* 5. SentinelInfoModal extracted to page-local module
|
||||
* 6. page.tsx retains all frozen-contract orchestration items
|
||||
* 7. MeshChat and MaplibreViewer integration boundaries remain intact
|
||||
* 8. No admin-session or proxy regression introduced
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const APP_DIR = path.resolve(__dirname, '../../app');
|
||||
|
||||
function readAppFile(name: string): string {
|
||||
return fs.readFileSync(path.join(APP_DIR, name), 'utf-8');
|
||||
}
|
||||
|
||||
// ─── Extraction verification ────────────────────────────────────────────────
|
||||
|
||||
describe('page.tsx decomposition — extraction targets', () => {
|
||||
it('LocateBar is defined in its own page-local module', () => {
|
||||
const locateBar = readAppFile('LocateBar.tsx');
|
||||
expect(locateBar).toMatch(/export\s+function\s+LocateBar/);
|
||||
expect(locateBar).toContain('onLocate');
|
||||
expect(locateBar).toContain('onOpenChange');
|
||||
});
|
||||
|
||||
it('SentinelInfoModal is defined in its own page-local module', () => {
|
||||
const modal = readAppFile('SentinelInfoModal.tsx');
|
||||
expect(modal).toMatch(/export\s+function\s+SentinelInfoModal/);
|
||||
expect(modal).toContain('onClose');
|
||||
expect(modal).toContain('SENTINEL HUB IMAGERY');
|
||||
});
|
||||
|
||||
it('page.tsx imports LocateBar from page-local module', () => {
|
||||
const page = readAppFile('page.tsx');
|
||||
expect(page).toMatch(/import\s*\{.*LocateBar.*\}\s*from\s+['"]\.\/LocateBar['"]/);
|
||||
});
|
||||
|
||||
it('page.tsx imports SentinelInfoModal from page-local module', () => {
|
||||
const page = readAppFile('page.tsx');
|
||||
expect(page).toMatch(/import\s*\{.*SentinelInfoModal.*\}\s*from\s+['"]\.\/SentinelInfoModal['"]/);
|
||||
});
|
||||
|
||||
it('page.tsx no longer defines LocateBar inline', () => {
|
||||
const page = readAppFile('page.tsx');
|
||||
// Should not have the old inline function definition
|
||||
expect(page).not.toMatch(/^function\s+LocateBar\s*\(/m);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── InfonetTerminal onClose wormhole teardown ──────────────────────────────
|
||||
|
||||
describe('page.tsx decomposition — InfonetTerminal onClose wormhole teardown', () => {
|
||||
const page = readAppFile('page.tsx');
|
||||
|
||||
it('InfonetTerminal onClose delegates to teardownWormholeOnClose', () => {
|
||||
const infonetSection = page.slice(
|
||||
page.indexOf('<InfonetTerminal'),
|
||||
page.indexOf('</InfonetTerminal>') !== -1
|
||||
? page.indexOf('</InfonetTerminal>')
|
||||
: page.indexOf('/>', page.indexOf('<InfonetTerminal')) + 2,
|
||||
);
|
||||
expect(infonetSection).toContain('teardownWormholeOnClose');
|
||||
expect(infonetSection).toContain('fetchWormholeState');
|
||||
expect(infonetSection).toContain('leaveWormhole');
|
||||
});
|
||||
|
||||
it('page.tsx imports teardownWormholeOnClose from wormholeTeardown', () => {
|
||||
expect(page).toMatch(
|
||||
/import\s*\{[^}]*teardownWormholeOnClose[^}]*\}\s*from\s+['"]@\/lib\/wormholeTeardown['"]/,
|
||||
);
|
||||
});
|
||||
|
||||
it('page.tsx imports leaveWormhole and fetchWormholeState from wormholeClient', () => {
|
||||
expect(page).toMatch(
|
||||
/import\s*\{[^}]*leaveWormhole[^}]*\}\s*from\s+['"]@\/mesh\/wormholeClient['"]/,
|
||||
);
|
||||
expect(page).toMatch(
|
||||
/import\s*\{[^}]*fetchWormholeState[^}]*\}\s*from\s+['"]@\/mesh\/wormholeClient['"]/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── /api/layers sync: first mount vs later changes ─────────────────────────
|
||||
|
||||
describe('page.tsx decomposition — /api/layers sync behavior', () => {
|
||||
const page = readAppFile('page.tsx');
|
||||
|
||||
it('uses initialLayerSyncRef to distinguish first sync from later changes', () => {
|
||||
expect(page).toContain('initialLayerSyncRef');
|
||||
// Check that initialLayerSyncRef is created as a ref
|
||||
expect(page).toMatch(/initialLayerSyncRef\s*=\s*useRef\s*\(\s*false\s*\)/);
|
||||
});
|
||||
|
||||
it('first mount sync passes false to triggerRefetch (no LAYER_TOGGLE_EVENT)', () => {
|
||||
// The code checks if initialLayerSyncRef.current is false, then calls syncLayers(false)
|
||||
expect(page).toMatch(/if\s*\(\s*!initialLayerSyncRef\.current\s*\)/);
|
||||
// After the check, it sets the ref to true and calls with false
|
||||
expect(page).toContain('syncLayers(false)');
|
||||
});
|
||||
|
||||
it('subsequent changes dispatch LAYER_TOGGLE_EVENT via syncLayers(true)', () => {
|
||||
expect(page).toContain('syncLayers(true)');
|
||||
});
|
||||
|
||||
it('LAYER_TOGGLE_EVENT is imported and dispatched inside syncLayers when triggerRefetch=true', () => {
|
||||
expect(page).toMatch(/import\s*\{[^}]*LAYER_TOGGLE_EVENT[^}]*\}/);
|
||||
expect(page).toMatch(/LAYER_TOGGLE_EVENT/);
|
||||
// dispatched conditionally on triggerRefetch
|
||||
expect(page).toMatch(/if\s*\(\s*triggerRefetch\s*\)/);
|
||||
expect(page).toContain('new Event(LAYER_TOGGLE_EVENT)');
|
||||
});
|
||||
|
||||
it('activeLayers state is defined in page.tsx (not moved to hook/context)', () => {
|
||||
expect(page).toMatch(/\[activeLayers,\s*setActiveLayers\]\s*=\s*useState/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── launchMeshChatTab atomic update ────────────────────────────────────────
|
||||
|
||||
describe('page.tsx decomposition — launchMeshChatTab atomicity', () => {
|
||||
const page = readAppFile('page.tsx');
|
||||
|
||||
it('launchMeshChatTab sets leftOpen to true', () => {
|
||||
// Extract the launchMeshChatTab definition
|
||||
const idx = page.indexOf('launchMeshChatTab');
|
||||
const block = page.slice(idx, idx + 300);
|
||||
expect(block).toContain('setLeftOpen(true)');
|
||||
});
|
||||
|
||||
it('launchMeshChatTab sets leftMeshExpanded to true', () => {
|
||||
const idx = page.indexOf('launchMeshChatTab');
|
||||
const block = page.slice(idx, idx + 300);
|
||||
expect(block).toContain('setLeftMeshExpanded(true)');
|
||||
});
|
||||
|
||||
it('launchMeshChatTab sets meshChatLaunchRequest with tab, gate, peerId, showSas, and nonce', () => {
|
||||
const idx = page.indexOf('launchMeshChatTab');
|
||||
const block = page.slice(idx, idx + 500);
|
||||
expect(block).toContain('setMeshChatLaunchRequest');
|
||||
expect(block).toMatch(/tab.*gate.*peerId.*showSas.*nonce|nonce.*Date\.now/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MeshChat and MaplibreViewer integration boundaries ─────────────────────
|
||||
|
||||
describe('page.tsx decomposition — child component integration', () => {
|
||||
const page = readAppFile('page.tsx');
|
||||
|
||||
it('MeshChat receives onFlyTo, expanded, onExpandedChange, onSettingsClick, onTerminalToggle, launchRequest props', () => {
|
||||
const meshChatIdx = page.indexOf('<MeshChat');
|
||||
const meshChatBlock = page.slice(meshChatIdx, meshChatIdx + 500);
|
||||
expect(meshChatBlock).toContain('onFlyTo');
|
||||
expect(meshChatBlock).toContain('expanded=');
|
||||
expect(meshChatBlock).toContain('onExpandedChange');
|
||||
expect(meshChatBlock).toContain('onSettingsClick');
|
||||
expect(meshChatBlock).toContain('onTerminalToggle');
|
||||
expect(meshChatBlock).toContain('launchRequest');
|
||||
});
|
||||
|
||||
it('MaplibreViewer receives activeLayers and viewBoundsRef props', () => {
|
||||
const mapIdx = page.indexOf('<MaplibreViewer');
|
||||
const mapBlock = page.slice(mapIdx, mapIdx + 1500);
|
||||
expect(mapBlock).toContain('activeLayers');
|
||||
expect(mapBlock).toContain('viewBoundsRef');
|
||||
});
|
||||
|
||||
it('page.tsx imports MeshChat from @/components/MeshChat', () => {
|
||||
expect(page).toMatch(/import\s+MeshChat\s+from\s+['"]@\/components\/MeshChat['"]/);
|
||||
});
|
||||
|
||||
it('page.tsx imports MaplibreViewer dynamically', () => {
|
||||
expect(page).toMatch(/dynamic\s*\(\s*\(\)\s*=>\s*import\s*\(\s*['"]@\/components\/MaplibreViewer['"]\s*\)/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── No admin-session or proxy regression ───────────────────────────────────
|
||||
|
||||
describe('page.tsx decomposition — no admin-session/proxy regression', () => {
|
||||
const page = readAppFile('page.tsx');
|
||||
|
||||
it('page.tsx still uses useDataPolling at top level', () => {
|
||||
expect(page).toMatch(/useDataPolling\s*\(\s*\)/);
|
||||
});
|
||||
|
||||
it('page.tsx still uses useBackendStatus', () => {
|
||||
expect(page).toContain('useBackendStatus');
|
||||
});
|
||||
|
||||
it('page.tsx does not import admin session utilities directly (they stay in hooks)', () => {
|
||||
// Admin session handling is in useDataPolling and backend hooks, not page.tsx
|
||||
expect(page).not.toMatch(/adminSession|admin_session/i);
|
||||
});
|
||||
|
||||
it('LocateBar uses backend proxy for geocoding (not direct-only)', () => {
|
||||
const locateBar = readAppFile('LocateBar.tsx');
|
||||
expect(locateBar).toContain('API_BASE');
|
||||
expect(locateBar).toContain('/api/geocode/search');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── page.tsx retains all frozen-contract orchestration ─────────────────────
|
||||
|
||||
describe('page.tsx decomposition — retained orchestration', () => {
|
||||
const page = readAppFile('page.tsx');
|
||||
|
||||
it('page.tsx retains cycleStyle with atomic activeStyle + highres_satellite update', () => {
|
||||
expect(page).toMatch(/cycleStyle/);
|
||||
const idx = page.indexOf('cycleStyle');
|
||||
const block = page.slice(idx, idx + 300);
|
||||
expect(block).toContain('setActiveStyle');
|
||||
expect(block).toContain('highres_satellite');
|
||||
});
|
||||
|
||||
it('page.tsx retains viewBoundsRef', () => {
|
||||
expect(page).toMatch(/viewBoundsRef\s*=\s*useRef/);
|
||||
});
|
||||
|
||||
it('page.tsx retains SSR-safe localStorage hydration', () => {
|
||||
expect(page).toContain('localStorage.getItem');
|
||||
expect(page).toContain('sb_left_open');
|
||||
expect(page).toContain('sb_right_open');
|
||||
});
|
||||
|
||||
it('page.tsx retains infonetOpen state', () => {
|
||||
expect(page).toMatch(/\[infonetOpen,\s*setInfonetOpen\]\s*=\s*useState/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Sprint 1C: Proxy admin-key injection coverage tests.
|
||||
*
|
||||
* Verifies that the server-side catch-all proxy injects X-Admin-Key on the
|
||||
* backend leg for routes guarded by require_local_operator:
|
||||
* - /api/mesh/peers (Sprint 1C addition)
|
||||
* - /api/tools/* (Sprint 1C addition)
|
||||
* - /api/wormhole/* (pre-existing, regression)
|
||||
* - /api/settings/* (pre-existing, regression)
|
||||
*
|
||||
* Also verifies that:
|
||||
* - non-sensitive mesh paths (e.g. mesh/events) do NOT receive injected key
|
||||
* - browser-supplied x-admin-key is stripped before forwarding (not trusted)
|
||||
* - no-store cache headers are set on all sensitive paths
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { GET as proxyGet, POST as proxyPost } from '@/app/api/[...path]/route';
|
||||
import {
|
||||
POST as postAdminSession,
|
||||
} from '@/app/api/admin/session/route';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractSessionCookie(setCookie: string): string {
|
||||
return setCookie.split(';')[0] || '';
|
||||
}
|
||||
|
||||
/** Mint a valid admin session and return the raw cookie string. */
|
||||
async function mintSession(adminKey: string): Promise<string> {
|
||||
const verifyMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', verifyMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
const res = await postAdminSession(req);
|
||||
return extractSessionCookie(res.headers.get('set-cookie') || '');
|
||||
}
|
||||
|
||||
/** Return the Headers object forwarded to the upstream fetch call. */
|
||||
function capturedHeaders(fetchMock: ReturnType<typeof vi.fn>): Headers {
|
||||
const forwarded = fetchMock.mock.calls[0]?.[1];
|
||||
return new Headers((forwarded as RequestInit | undefined)?.headers);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('proxy admin-key injection coverage', () => {
|
||||
const ADMIN_KEY = 'a-valid-admin-key-that-is-at-least-32chars!!';
|
||||
const originalAdminKey = process.env.ADMIN_KEY;
|
||||
const originalBackendUrl = process.env.BACKEND_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.ADMIN_KEY = ADMIN_KEY;
|
||||
process.env.BACKEND_URL = 'http://127.0.0.1:8000';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ADMIN_KEY = originalAdminKey;
|
||||
process.env.BACKEND_URL = originalBackendUrl;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Sprint 1C: mesh/peers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('GET /api/mesh/peers with valid session injects X-Admin-Key', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ peers: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/mesh/peers', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
const res = await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['mesh', 'peers'] }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
|
||||
});
|
||||
|
||||
it('POST /api/mesh/peers with valid session injects X-Admin-Key', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/mesh/peers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url: 'http://peer.example.com:8000' }),
|
||||
headers: { cookie, 'Content-Type': 'application/json' },
|
||||
});
|
||||
const res = await proxyPost(req, {
|
||||
params: Promise.resolve({ path: ['mesh', 'peers'] }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
|
||||
});
|
||||
|
||||
it('GET /api/mesh/peers applies no-store cache headers', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ peers: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/mesh/peers', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
const res = await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['mesh', 'peers'] }),
|
||||
});
|
||||
|
||||
expect(res.headers.get('cache-control')).toContain('no-store');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Sprint 1C: tools/*
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('POST /api/tools/shodan/search with valid session injects X-Admin-Key', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ results: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/tools/shodan/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query: 'port:22' }),
|
||||
headers: { cookie, 'Content-Type': 'application/json' },
|
||||
});
|
||||
const res = await proxyPost(req, {
|
||||
params: Promise.resolve({ path: ['tools', 'shodan', 'search'] }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
|
||||
});
|
||||
|
||||
it('GET /api/tools/uw/status with valid session injects X-Admin-Key', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ configured: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/tools/uw/status', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
const res = await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['tools', 'uw', 'status'] }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
|
||||
});
|
||||
|
||||
it('GET /api/tools/shodan/status applies no-store cache headers', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ configured: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/tools/shodan/status', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
const res = await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['tools', 'shodan', 'status'] }),
|
||||
});
|
||||
|
||||
expect(res.headers.get('cache-control')).toContain('no-store');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Regression: wormhole/* and settings/* unchanged
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('GET /api/wormhole/identity with valid session still injects X-Admin-Key', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ identity: null }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/wormhole/identity', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
const res = await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['wormhole', 'identity'] }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
|
||||
});
|
||||
|
||||
it('GET /api/settings/node with valid session still injects X-Admin-Key', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ node: {} }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/settings/node', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
const res = await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['settings', 'node'] }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Non-sensitive mesh paths must NOT receive injected admin key
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('GET /api/mesh/events does NOT inject X-Admin-Key', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response('data: {}\n\n', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/mesh/events', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['mesh', 'events'] }),
|
||||
});
|
||||
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBeNull();
|
||||
});
|
||||
|
||||
it('GET /api/mesh/infonet/feed does NOT inject X-Admin-Key', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ items: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/mesh/infonet/feed', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['mesh', 'infonet', 'feed'] }),
|
||||
});
|
||||
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBeNull();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Browser-supplied x-admin-key is stripped on all paths
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('browser-supplied x-admin-key is stripped on mesh/peers path', async () => {
|
||||
process.env.ADMIN_KEY = '';
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ peers: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/mesh/peers', {
|
||||
method: 'GET',
|
||||
headers: { 'x-admin-key': 'browser-injected-key' },
|
||||
});
|
||||
await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['mesh', 'peers'] }),
|
||||
});
|
||||
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBeNull();
|
||||
});
|
||||
|
||||
it('browser-supplied x-admin-key is stripped on tools path', async () => {
|
||||
process.env.ADMIN_KEY = '';
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ configured: false }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/tools/shodan/status', {
|
||||
method: 'GET',
|
||||
headers: { 'x-admin-key': 'browser-injected-key' },
|
||||
});
|
||||
await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['tools', 'shodan', 'status'] }),
|
||||
});
|
||||
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { NOMINATIM_DEBOUNCE_MS } from '@/lib/constants';
|
||||
|
||||
/* ── LOCATE BAR ── coordinate / place-name search above bottom status bar ── */
|
||||
export function LocateBar({ onLocate, onOpenChange }: { onLocate: (lat: number, lng: number) => void; onOpenChange?: (open: boolean) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => { onOpenChange?.(open); }, [open]);
|
||||
const [value, setValue] = useState('');
|
||||
const [results, setResults] = useState<{ label: string; lat: number; lng: number }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const searchAbortRef = useRef<AbortController | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
// Close when clicking outside
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setValue('');
|
||||
setResults([]);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
// Parse raw coordinate input: "31.8, 34.8" or "31.8 34.8" or "-12.3, 45.6"
|
||||
const parseCoords = (s: string): { lat: number; lng: number } | null => {
|
||||
const m = s.trim().match(/^([+-]?\d+\.?\d*)[,\s]+([+-]?\d+\.?\d*)$/);
|
||||
if (!m) return null;
|
||||
const lat = parseFloat(m[1]),
|
||||
lng = parseFloat(m[2]);
|
||||
if (lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) return { lat, lng };
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSearch = async (q: string) => {
|
||||
setValue(q);
|
||||
// Check for raw coordinates first
|
||||
const coords = parseCoords(q);
|
||||
if (coords) {
|
||||
setResults([{ label: `${coords.lat.toFixed(4)}, ${coords.lng.toFixed(4)}`, ...coords }]);
|
||||
return;
|
||||
}
|
||||
// Geocode with Nominatim (debounced)
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (searchAbortRef.current) searchAbortRef.current.abort();
|
||||
if (q.trim().length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
timerRef.current = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
searchAbortRef.current = new AbortController();
|
||||
const signal = searchAbortRef.current.signal;
|
||||
try {
|
||||
// Try backend proxy first (has caching + rate-limit compliance)
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/geocode/search?q=${encodeURIComponent(q)}&limit=5`,
|
||||
{ signal },
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const mapped = (data?.results || []).map(
|
||||
(r: { label: string; lat: number; lng: number }) => ({
|
||||
label: r.label,
|
||||
lat: r.lat,
|
||||
lng: r.lng,
|
||||
}),
|
||||
);
|
||||
setResults(mapped);
|
||||
} else {
|
||||
// Backend proxy returned an error — fall back to direct Nominatim
|
||||
console.warn(`[Locate] Proxy returned HTTP ${res.status}, falling back to Nominatim`);
|
||||
const directRes = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=5`,
|
||||
{ headers: { 'Accept-Language': 'en' }, signal },
|
||||
);
|
||||
const data = await directRes.json();
|
||||
setResults(
|
||||
data.map((r: { display_name: string; lat: string; lon: string }) => ({
|
||||
label: r.display_name,
|
||||
lat: parseFloat(r.lat),
|
||||
lng: parseFloat(r.lon),
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name !== 'AbortError') {
|
||||
// Proxy completely failed — try direct Nominatim as last resort
|
||||
try {
|
||||
const directRes = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=5`,
|
||||
{ headers: { 'Accept-Language': 'en' } },
|
||||
);
|
||||
const data = await directRes.json();
|
||||
setResults(
|
||||
data.map((r: { display_name: string; lat: string; lon: string }) => ({
|
||||
label: r.display_name,
|
||||
lat: parseFloat(r.lat),
|
||||
lng: parseFloat(r.lon),
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
setResults([]);
|
||||
}
|
||||
} else {
|
||||
setResults([]);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, NOMINATIM_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const handleSelect = (r: { lat: number; lng: number }) => {
|
||||
onLocate(r.lat, r.lng);
|
||||
setOpen(false);
|
||||
setValue('');
|
||||
setResults([]);
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center gap-2 bg-[var(--bg-primary)]/80 border border-[var(--border-primary)] px-5 py-2 text-[11px] font-mono tracking-[0.15em] text-[var(--text-muted)] hover:text-cyan-400 hover:border-cyan-800 transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
LOCATE
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-[520px]">
|
||||
<div className="flex items-center gap-2 bg-[var(--bg-primary)] border border-cyan-800/60 px-4 py-2.5 shadow-[0_0_20px_rgba(0,255,255,0.1)]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-cyan-500 flex-shrink-0"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setValue('');
|
||||
setResults([]);
|
||||
}
|
||||
if (e.key === 'Enter' && results.length > 0) handleSelect(results[0]);
|
||||
}}
|
||||
placeholder="Enter coordinates (31.8, 34.8) or place name..."
|
||||
className="flex-1 bg-transparent text-[12px] text-[var(--text-primary)] font-mono tracking-wider outline-none placeholder:text-[var(--text-muted)]"
|
||||
/>
|
||||
{loading && (
|
||||
<div className="w-3 h-3 border border-cyan-500 border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setValue('');
|
||||
setResults([]);
|
||||
}}
|
||||
className="text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-1 bg-[var(--bg-secondary)] border border-[var(--border-primary)] overflow-hidden shadow-[0_-8px_30px_rgba(0,0,0,0.4)] max-h-[200px] overflow-y-auto styled-scrollbar">
|
||||
{results.map((r, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleSelect(r)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-cyan-950/40 transition-colors border-b border-[var(--border-primary)]/50 last:border-0 flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-cyan-500 flex-shrink-0"
|
||||
>
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
<span className="text-[11px] text-[var(--text-secondary)] font-mono truncate">
|
||||
{r.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
/* ── SENTINEL HUB — first-time info modal ── */
|
||||
export function SentinelInfoModal({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/90"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative z-[10001] w-[520px] max-h-[80vh] bg-[var(--bg-secondary)] border border-purple-500/30 shadow-2xl shadow-purple-900/20 overflow-y-auto styled-scrollbar">
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold tracking-wider text-purple-300 font-mono">
|
||||
SENTINEL HUB IMAGERY
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[var(--text-muted)] hover:text-white transition-colors text-xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
You now have access to ESA Sentinel-2 satellite imagery directly on the map.
|
||||
This uses the Copernicus Data Space Ecosystem with your own credentials.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[10px] font-mono text-purple-400 tracking-widest">AVAILABLE LAYERS</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ name: 'True Color', desc: 'Natural RGB — see terrain, cities, water' },
|
||||
{ name: 'False Color IR', desc: 'Near-infrared — vegetation in red' },
|
||||
{ name: 'NDVI', desc: 'Vegetation health index (green = healthy)' },
|
||||
{ name: 'Moisture Index', desc: 'Soil & vegetation moisture levels' },
|
||||
].map((l) => (
|
||||
<div key={l.name} className="p-2 border border-purple-900/30 bg-purple-950/10">
|
||||
<div className="text-[10px] font-mono text-white">{l.name}</div>
|
||||
<div className="text-[9px] text-[var(--text-muted)]">{l.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[10px] font-mono text-purple-400 tracking-widest">USAGE LIMITS (FREE TIER)</h3>
|
||||
<div className="p-3 border border-[var(--border-primary)] bg-[var(--bg-primary)]/40 space-y-1.5">
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-[var(--text-muted)]">Monthly budget</span>
|
||||
<span className="text-purple-300">10,000 requests</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-[var(--text-muted)]">Cost per tile</span>
|
||||
<span className="text-purple-300">0.25 PU (256×256px)</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-[var(--text-muted)]">~Viewport loads/month</span>
|
||||
<span className="text-purple-300">~500 (20 tiles each)</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-[var(--text-muted)]">Empty tiles</span>
|
||||
<span className="text-green-400">FREE (no data = no charge)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[10px] font-mono text-purple-400 tracking-widest">HOW IT WORKS</h3>
|
||||
<ul className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed space-y-1 list-disc list-inside">
|
||||
<li>Sentinel-2 revisits every ~5 days — not every location has data every day</li>
|
||||
<li>The date slider picks the end of a time window; zoomed out uses wider windows</li>
|
||||
<li>Black patches = no satellite pass on that date range (normal)</li>
|
||||
<li>Best results at zoom 8-14 — closer = sharper imagery (10m resolution)</li>
|
||||
<li>Cloud filter auto-skips tiles with {'>'} 30% cloud cover</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full py-2.5 bg-purple-500/20 border border-purple-500/40 text-purple-300 hover:bg-purple-500/30 transition-colors text-[11px] font-mono tracking-wider"
|
||||
>
|
||||
GOT IT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,10 @@ function isSensitiveProxyPath(pathSegments: string[]): boolean {
|
||||
if (joined === 'system/update') return true;
|
||||
if (pathSegments[0] === 'settings') return true;
|
||||
if (joined === 'mesh/infonet/ingest') return true;
|
||||
// mesh/peers and all tools/* use require_local_operator on the backend and
|
||||
// need X-Admin-Key injected on the server-side proxy leg.
|
||||
if (pathSegments[0] === 'mesh' && pathSegments[1] === 'peers') return true;
|
||||
if (pathSegments[0] === 'tools') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,13 +12,11 @@ const NO_STORE_HEADERS = {
|
||||
Pragma: 'no-cache',
|
||||
};
|
||||
|
||||
function cookieOptions(req: NextRequest) {
|
||||
const host = req.headers.get('host') ?? '';
|
||||
const isLoopback = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host);
|
||||
function cookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict' as const,
|
||||
secure: process.env.NODE_ENV === 'production' && !isLoopback,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
};
|
||||
@@ -82,7 +80,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
const sessionToken = createAdminSessionToken(adminKey, COOKIE_MAX_AGE);
|
||||
const res = NextResponse.json({ ok: true }, { headers: NO_STORE_HEADERS });
|
||||
res.cookies.set(COOKIE_NAME, sessionToken, cookieOptions(req));
|
||||
res.cookies.set(COOKIE_NAME, sessionToken, cookieOptions());
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -93,7 +91,7 @@ export async function DELETE(req: NextRequest) {
|
||||
}
|
||||
const res = NextResponse.json({ ok: true }, { headers: NO_STORE_HEADERS });
|
||||
res.cookies.set(COOKIE_NAME, '', {
|
||||
...cookieOptions(req),
|
||||
...cookieOptions(),
|
||||
maxAge: 0,
|
||||
});
|
||||
return res;
|
||||
|
||||
@@ -136,19 +136,19 @@ textarea:disabled {
|
||||
background: rgba(10, 14, 20, 0.96);
|
||||
border-radius: 2px;
|
||||
border: 1px solid rgba(8, 145, 178, 0.35);
|
||||
padding: 10px 14px;
|
||||
padding: 12px 16px;
|
||||
color: #d1d5db;
|
||||
font-family:
|
||||
'JetBrains Mono', var(--font-roboto-mono), 'Roboto Mono', monospace, 'Microsoft YaHei', 'PingFang SC',
|
||||
'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR', sans-serif;
|
||||
font-size: 11px;
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
font-size: 13px;
|
||||
min-width: 240px;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.map-popup-title {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
font-size: 16px;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
@@ -159,12 +159,18 @@ textarea:disabled {
|
||||
}
|
||||
|
||||
.map-popup-subtitle {
|
||||
font-size: 9px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Hide MapLibre logo & attribution */
|
||||
.maplibregl-ctrl-logo,
|
||||
.maplibregl-ctrl-attrib {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* MapLibre Popup Overrides */
|
||||
.maplibregl-popup-content {
|
||||
background: transparent !important;
|
||||
@@ -458,3 +464,61 @@ textarea:disabled {
|
||||
.crt ::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
/* ── THREAT ALERT ACCESSIBILITY PATTERNS ── */
|
||||
/* Border patterns for color-blind differentiation beyond hue */
|
||||
.threat-border-critical {
|
||||
border-width: 3px !important;
|
||||
border-style: solid !important;
|
||||
box-shadow: 0 0 12px rgba(239, 68, 68, 0.4), inset 0 0 12px rgba(239, 68, 68, 0.1);
|
||||
animation: threat-pulse-critical 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.threat-border-high {
|
||||
border-width: 2px !important;
|
||||
border-style: solid !important;
|
||||
}
|
||||
|
||||
.threat-border-medium {
|
||||
border-width: 2px !important;
|
||||
border-style: dashed !important;
|
||||
}
|
||||
|
||||
.threat-border-low {
|
||||
border-width: 1px !important;
|
||||
border-style: dotted !important;
|
||||
}
|
||||
|
||||
@keyframes threat-pulse-critical {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 12px rgba(239, 68, 68, 0.4), inset 0 0 12px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(239, 68, 68, 0.6), inset 0 0 20px rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── FEED HEALTH INDICATOR DOTS ── */
|
||||
.feed-dot {
|
||||
display: inline-block;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
margin-right: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.feed-dot-healthy {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 4px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.feed-dot-stale {
|
||||
background: #eab308;
|
||||
box-shadow: 0 0 4px rgba(234, 179, 8, 0.5);
|
||||
}
|
||||
|
||||
.feed-dot-offline {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 4px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
+192
-369
@@ -11,7 +11,7 @@ import MarketsPanel from '@/components/MarketsPanel';
|
||||
import FilterPanel from '@/components/FilterPanel';
|
||||
import FindLocateBar from '@/components/FindLocateBar';
|
||||
import TopRightControls from '@/components/TopRightControls';
|
||||
import PredictionsPanel from '@/components/PredictionsPanel';
|
||||
import TimelinePanel from '@/components/TimelinePanel';
|
||||
import SettingsPanel from '@/components/SettingsPanel';
|
||||
import MapLegend from '@/components/MapLegend';
|
||||
import ScaleBar from '@/components/ScaleBar';
|
||||
@@ -19,19 +19,28 @@ import MeshTerminal from '@/components/MeshTerminal';
|
||||
import MeshChat from '@/components/MeshChat';
|
||||
import InfonetTerminal from '@/components/InfonetTerminal';
|
||||
import { leaveWormhole, fetchWormholeState } from '@/mesh/wormholeClient';
|
||||
import { teardownWormholeOnClose } from '@/lib/wormholeTeardown';
|
||||
import ShodanPanel from '@/components/ShodanPanel';
|
||||
import AIIntelPanel from '@/components/AIIntelPanel';
|
||||
import GlobalTicker from '@/components/GlobalTicker';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import OnboardingModal, { useOnboarding } from '@/components/OnboardingModal';
|
||||
import ChangelogModal, { useChangelog } from '@/components/ChangelogModal';
|
||||
import type { ActiveLayers, KiwiSDR, Scanner, SelectedEntity } from '@/types/dashboard';
|
||||
import type { ShodanSearchMatch } from '@/types/shodan';
|
||||
import { NOMINATIM_DEBOUNCE_MS } from '@/lib/constants';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { useDataPolling, LAYER_TOGGLE_EVENT } from '@/hooks/useDataPolling';
|
||||
import { useBackendStatus, useDataKey } from '@/hooks/useDataStore';
|
||||
import { useReverseGeocode } from '@/hooks/useReverseGeocode';
|
||||
import { useRegionDossier } from '@/hooks/useRegionDossier';
|
||||
import { useAgentActions } from '@/hooks/useAgentActions';
|
||||
import { useFeedHealth } from '@/hooks/useFeedHealth';
|
||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
||||
import KeyboardShortcutsOverlay from '@/components/KeyboardShortcutsOverlay';
|
||||
import AlertToast from '@/components/AlertToast';
|
||||
import { useAlertToasts } from '@/hooks/useAlertToasts';
|
||||
import { useWatchlist } from '@/hooks/useWatchlist';
|
||||
import WatchlistWidget from '@/components/WatchlistWidget';
|
||||
import {
|
||||
requestSecureMeshTerminalLauncherOpen,
|
||||
subscribeMeshTerminalOpen,
|
||||
@@ -40,257 +49,15 @@ import {
|
||||
hasSentinelInfoBeenSeen,
|
||||
markSentinelInfoSeen,
|
||||
hasSentinelCredentials,
|
||||
getSentinelUsage,
|
||||
} from '@/lib/sentinelHub';
|
||||
import { LocateBar } from './LocateBar';
|
||||
import { SentinelInfoModal } from './SentinelInfoModal';
|
||||
import SarAoiEditorModal from '@/components/SarAoiEditorModal';
|
||||
|
||||
// Use dynamic loads for Maplibre to avoid SSR window is not defined errors
|
||||
const MaplibreViewer = dynamic(() => import('@/components/MaplibreViewer'), { ssr: false });
|
||||
|
||||
/* ── LOCATE BAR ── coordinate / place-name search above bottom status bar ── */
|
||||
function LocateBar({ onLocate, onOpenChange }: { onLocate: (lat: number, lng: number) => void; onOpenChange?: (open: boolean) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => { onOpenChange?.(open); }, [open]);
|
||||
const [value, setValue] = useState('');
|
||||
const [results, setResults] = useState<{ label: string; lat: number; lng: number }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const searchAbortRef = useRef<AbortController | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
// Close when clicking outside
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setValue('');
|
||||
setResults([]);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
// Parse raw coordinate input: "31.8, 34.8" or "31.8 34.8" or "-12.3, 45.6"
|
||||
const parseCoords = (s: string): { lat: number; lng: number } | null => {
|
||||
const m = s.trim().match(/^([+-]?\d+\.?\d*)[,\s]+([+-]?\d+\.?\d*)$/);
|
||||
if (!m) return null;
|
||||
const lat = parseFloat(m[1]),
|
||||
lng = parseFloat(m[2]);
|
||||
if (lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) return { lat, lng };
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSearch = async (q: string) => {
|
||||
setValue(q);
|
||||
// Check for raw coordinates first
|
||||
const coords = parseCoords(q);
|
||||
if (coords) {
|
||||
setResults([{ label: `${coords.lat.toFixed(4)}, ${coords.lng.toFixed(4)}`, ...coords }]);
|
||||
return;
|
||||
}
|
||||
// Geocode with Nominatim (debounced)
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (searchAbortRef.current) searchAbortRef.current.abort();
|
||||
if (q.trim().length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
timerRef.current = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
searchAbortRef.current = new AbortController();
|
||||
const signal = searchAbortRef.current.signal;
|
||||
try {
|
||||
// Try backend proxy first (has caching + rate-limit compliance)
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/geocode/search?q=${encodeURIComponent(q)}&limit=5`,
|
||||
{ signal },
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const mapped = (data?.results || []).map(
|
||||
(r: { label: string; lat: number; lng: number }) => ({
|
||||
label: r.label,
|
||||
lat: r.lat,
|
||||
lng: r.lng,
|
||||
}),
|
||||
);
|
||||
setResults(mapped);
|
||||
} else {
|
||||
// Backend proxy returned an error — fall back to direct Nominatim
|
||||
console.warn(`[Locate] Proxy returned HTTP ${res.status}, falling back to Nominatim`);
|
||||
const directRes = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=5`,
|
||||
{ headers: { 'Accept-Language': 'en' }, signal },
|
||||
);
|
||||
const data = await directRes.json();
|
||||
setResults(
|
||||
data.map((r: { display_name: string; lat: string; lon: string }) => ({
|
||||
label: r.display_name,
|
||||
lat: parseFloat(r.lat),
|
||||
lng: parseFloat(r.lon),
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name !== 'AbortError') {
|
||||
// Proxy completely failed — try direct Nominatim as last resort
|
||||
try {
|
||||
const directRes = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=5`,
|
||||
{ headers: { 'Accept-Language': 'en' } },
|
||||
);
|
||||
const data = await directRes.json();
|
||||
setResults(
|
||||
data.map((r: { display_name: string; lat: string; lon: string }) => ({
|
||||
label: r.display_name,
|
||||
lat: parseFloat(r.lat),
|
||||
lng: parseFloat(r.lon),
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
setResults([]);
|
||||
}
|
||||
} else {
|
||||
setResults([]);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, NOMINATIM_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const handleSelect = (r: { lat: number; lng: number }) => {
|
||||
onLocate(r.lat, r.lng);
|
||||
setOpen(false);
|
||||
setValue('');
|
||||
setResults([]);
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center gap-2 bg-[var(--bg-primary)]/80 border border-[var(--border-primary)] px-5 py-2 text-[11px] font-mono tracking-[0.15em] text-[var(--text-muted)] hover:text-cyan-400 hover:border-cyan-800 transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
LOCATE
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-[520px]">
|
||||
<div className="flex items-center gap-2 bg-[var(--bg-primary)] border border-cyan-800/60 px-4 py-2.5 shadow-[0_0_20px_rgba(0,255,255,0.1)]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-cyan-500 flex-shrink-0"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setValue('');
|
||||
setResults([]);
|
||||
}
|
||||
if (e.key === 'Enter' && results.length > 0) handleSelect(results[0]);
|
||||
}}
|
||||
placeholder="Enter coordinates (31.8, 34.8) or place name..."
|
||||
className="flex-1 bg-transparent text-[12px] text-[var(--text-primary)] font-mono tracking-wider outline-none placeholder:text-[var(--text-muted)]"
|
||||
/>
|
||||
{loading && (
|
||||
<div className="w-3 h-3 border border-cyan-500 border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setValue('');
|
||||
setResults([]);
|
||||
}}
|
||||
className="text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-1 bg-[var(--bg-secondary)] border border-[var(--border-primary)] overflow-hidden shadow-[0_-8px_30px_rgba(0,0,0,0.4)] max-h-[200px] overflow-y-auto styled-scrollbar">
|
||||
{results.map((r, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleSelect(r)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-cyan-950/40 transition-colors border-b border-[var(--border-primary)]/50 last:border-0 flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-cyan-500 flex-shrink-0"
|
||||
>
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
<span className="text-[11px] text-[var(--text-secondary)] font-mono truncate">
|
||||
{r.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// LocateBar and SentinelInfoModal extracted to page-local modules (Sprint 4B)
|
||||
|
||||
export default function Dashboard() {
|
||||
const viewBoundsRef = useRef<{ south: number; west: number; north: number; east: number } | null>(null);
|
||||
@@ -303,6 +70,11 @@ export default function Dashboard() {
|
||||
setSelectedEntity,
|
||||
);
|
||||
|
||||
// Agent can push satellite imagery to the same full-screen viewer as right-click,
|
||||
// and can fly the map to a point (e.g. sar_focus_aoi). The hook is invoked
|
||||
// below — after setFlyToLocation is declared — so the fly_to callback can
|
||||
// close over it without hitting a temporal dead zone.
|
||||
|
||||
const [uiVisible, setUiVisible] = useState(true);
|
||||
const [leftOpen, setLeftOpen] = useState(true);
|
||||
const [rightOpen, setRightOpen] = useState(true);
|
||||
@@ -331,12 +103,15 @@ export default function Dashboard() {
|
||||
}, [tickerOpen]);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [legendOpen, setLegendOpen] = useState(false);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [terminalLaunchToken, setTerminalLaunchToken] = useState(0);
|
||||
const [infonetOpen, setInfonetOpen] = useState(false);
|
||||
const [meshChatLaunchRequest, setMeshChatLaunchRequest] = useState<{
|
||||
tab: 'infonet' | 'meshtastic' | 'dms';
|
||||
gate?: string;
|
||||
peerId?: string;
|
||||
showSas?: boolean;
|
||||
nonce: number;
|
||||
} | null>(null);
|
||||
const [dmCount, setDmCount] = useState(0);
|
||||
@@ -344,6 +119,14 @@ export default function Dashboard() {
|
||||
const [locateBarOpen, setLocateBarOpen] = useState(false);
|
||||
const [measureMode, setMeasureMode] = useState(false);
|
||||
const [measurePoints, setMeasurePoints] = useState<{ lat: number; lng: number }[]>([]);
|
||||
const [pinPlacementMode, setPinPlacementMode] = useState(false);
|
||||
|
||||
// SAR AOI editor + map drop mode
|
||||
const [sarAoiEditorOpen, setSarAoiEditorOpen] = useState(false);
|
||||
const [sarAoiDropMode, setSarAoiDropMode] = useState(false);
|
||||
const [sarAoiDroppedCoords, setSarAoiDroppedCoords] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const sarAoiListChangedRef = useRef(0);
|
||||
const [sarAoiListVersion, setSarAoiListVersion] = useState(0);
|
||||
|
||||
const openMeshTerminal = useCallback(() => {
|
||||
setTerminalOpen(true);
|
||||
@@ -412,8 +195,18 @@ export default function Dashboard() {
|
||||
global_incidents: true,
|
||||
day_night: true,
|
||||
correlations: true,
|
||||
contradictions: true,
|
||||
uap_sightings: true,
|
||||
// Biosurveillance
|
||||
wastewater: true,
|
||||
// CrowdThreat
|
||||
crowdthreat: true,
|
||||
// Shodan
|
||||
shodan_overlay: false,
|
||||
// AI Intel
|
||||
ai_intel: true,
|
||||
// SAR (Synthetic Aperture Radar)
|
||||
sar: true,
|
||||
});
|
||||
const [shodanResults, setShodanResults] = useState<ShodanSearchMatch[]>([]);
|
||||
const [, setShodanQueryLabel] = useState('');
|
||||
@@ -421,6 +214,33 @@ export default function Dashboard() {
|
||||
useDataPolling();
|
||||
const backendStatus = useBackendStatus();
|
||||
const spaceWeather = useDataKey('space_weather');
|
||||
const feedHealth = useFeedHealth();
|
||||
|
||||
// Global keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
toggleLeft: () => setLeftOpen((p) => !p),
|
||||
toggleRight: () => setRightOpen((p) => !p),
|
||||
toggleMarkets: () => setTickerOpen((p) => !p),
|
||||
openSettings: () => setSettingsOpen(true),
|
||||
openLegend: () => setLegendOpen((p) => !p),
|
||||
openShortcuts: () => setShortcutsOpen((p) => !p),
|
||||
deselectEntity: () => {
|
||||
if (shortcutsOpen) { setShortcutsOpen(false); return; }
|
||||
if (settingsOpen) { setSettingsOpen(false); return; }
|
||||
if (legendOpen) { setLegendOpen(false); return; }
|
||||
setSelectedEntity(null);
|
||||
},
|
||||
focusSearch: () => {
|
||||
const el = document.querySelector<HTMLInputElement>('[data-search-input]');
|
||||
el?.focus();
|
||||
},
|
||||
});
|
||||
|
||||
// Alert toast notifications for high-severity news
|
||||
const { toasts, dismiss: dismissToast } = useAlertToasts();
|
||||
|
||||
// Persistent entity watchlist
|
||||
const { items: watchlistItems, removeFromWatchlist, clearWatchlist } = useWatchlist();
|
||||
|
||||
// Notify backend of layer toggles so it can skip disabled fetchers / stop streams.
|
||||
// After the POST completes, dispatch a custom event so useDataPolling immediately
|
||||
@@ -459,17 +279,33 @@ export default function Dashboard() {
|
||||
const [leftMeshExpanded, setLeftMeshExpanded] = useState(true);
|
||||
const [leftShodanMinimized, setLeftShodanMinimized] = useState(true);
|
||||
|
||||
const launchMeshChatTab = useCallback((tab: 'infonet' | 'meshtastic' | 'dms', gate?: string) => {
|
||||
setLeftOpen(true);
|
||||
setLeftMeshExpanded(true);
|
||||
setMeshChatLaunchRequest({ tab, gate, nonce: Date.now() });
|
||||
}, []);
|
||||
const launchMeshChatTab = useCallback(
|
||||
(
|
||||
tab: 'infonet' | 'meshtastic' | 'dms',
|
||||
gate?: string,
|
||||
peerId?: string,
|
||||
showSas?: boolean,
|
||||
) => {
|
||||
setLeftOpen(true);
|
||||
setLeftMeshExpanded(true);
|
||||
setMeshChatLaunchRequest({ tab, gate, peerId, showSas, nonce: Date.now() });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const openLiveGateFromShell = useCallback((gate: string) => {
|
||||
setInfonetOpen(false);
|
||||
launchMeshChatTab('infonet', gate);
|
||||
}, [launchMeshChatTab]);
|
||||
|
||||
const openDeadDropFromShell = useCallback(
|
||||
(peerId: string, options?: { showSas?: boolean }) => {
|
||||
setInfonetOpen(false);
|
||||
launchMeshChatTab('dms', undefined, peerId, Boolean(options?.showSas));
|
||||
},
|
||||
[launchMeshChatTab],
|
||||
);
|
||||
|
||||
// Right panel: which panel is "focused" (expanded). null = none focused, all normal.
|
||||
const [rightFocusedPanel, setRightFocusedPanel] = useState<string | null>(null);
|
||||
|
||||
@@ -557,6 +393,13 @@ export default function Dashboard() {
|
||||
ts: number;
|
||||
} | null>(null);
|
||||
|
||||
// Agent fly_to handler (sar_focus_aoi etc.) — wired here now that
|
||||
// setFlyToLocation is in scope. show_image is routed through
|
||||
// useAgentActions at the top of Dashboard.
|
||||
useAgentActions(handleMapRightClick, ({ lat, lng }) => {
|
||||
setFlyToLocation({ lat, lng, ts: Date.now() });
|
||||
});
|
||||
|
||||
// Eavesdrop Mode State
|
||||
const [isEavesdropping] = useState(false);
|
||||
const [, setEavesdropLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||
@@ -601,6 +444,15 @@ export default function Dashboard() {
|
||||
setTrackedScanner={setTrackedScanner}
|
||||
shodanResults={shodanResults}
|
||||
shodanStyle={shodanStyle}
|
||||
pinPlacementMode={pinPlacementMode}
|
||||
onPinPlaced={() => setPinPlacementMode(false)}
|
||||
sarAoiDropMode={sarAoiDropMode}
|
||||
onSarAoiDropped={(coords) => {
|
||||
setSarAoiDropMode(false);
|
||||
setSarAoiDroppedCoords(coords);
|
||||
setSarAoiEditorOpen(true);
|
||||
}}
|
||||
sarAoiListVersion={sarAoiListVersion}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -628,22 +480,18 @@ export default function Dashboard() {
|
||||
>
|
||||
S H A D O W <span className="text-cyan-400">B R O K E R</span>
|
||||
</h1>
|
||||
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-[0.3em] mt-1 ml-1">
|
||||
<span className="text-[11px] text-[var(--text-muted)] font-mono tracking-[0.3em] mt-1 ml-1">
|
||||
GLOBAL THREAT INTERCEPT
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* SYSTEM METRICS TOP LEFT */}
|
||||
<div className="absolute top-2 left-6 text-[8px] font-mono tracking-widest text-cyan-500/50 z-[200] pointer-events-none hud-zone">
|
||||
<div className="absolute top-2 left-6 text-[11px] font-mono tracking-widest text-cyan-500/50 z-[200] pointer-events-none hud-zone">
|
||||
OPTIC VIS:113 SRC:180 DENS:1.42 0.8ms
|
||||
</div>
|
||||
|
||||
{/* SYSTEM METRICS TOP RIGHT */}
|
||||
<div className="absolute top-2 right-6 text-[9px] flex flex-col items-end font-mono tracking-widest text-[var(--text-muted)] z-[200] pointer-events-none hud-zone">
|
||||
<div>RTX</div>
|
||||
<div>VSR</div>
|
||||
</div>
|
||||
{/* SYSTEM METRICS TOP RIGHT — removed, label moved into TimelineScrubber */}
|
||||
|
||||
{/* LEFT HUD CONTAINER — mirrors right side: one scroll container, scrollbar on LEFT edge */}
|
||||
<motion.div
|
||||
@@ -661,6 +509,7 @@ export default function Dashboard() {
|
||||
shodanResultCount={shodanResults.length}
|
||||
onSettingsClick={() => setSettingsOpen(true)}
|
||||
onLegendClick={() => setLegendOpen(true)}
|
||||
onOpenSarAoiEditor={() => setSarAoiEditorOpen(true)}
|
||||
gibsDate={gibsDate}
|
||||
setGibsDate={setGibsDate}
|
||||
gibsOpacity={gibsOpacity}
|
||||
@@ -712,6 +561,15 @@ export default function Dashboard() {
|
||||
onMinimizedChange={setLeftShodanMinimized}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 4. AI INTEL (Below Shodan) */}
|
||||
<div className="contents" style={{ direction: 'ltr' }}>
|
||||
<AIIntelPanel
|
||||
onFlyTo={handleFlyTo}
|
||||
pinPlacementMode={pinPlacementMode}
|
||||
onPinPlacementModeChange={setPinPlacementMode}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* LEFT SIDEBAR TOGGLE TAB — aligns with Data Layers section */}
|
||||
@@ -734,10 +592,10 @@ export default function Dashboard() {
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
{/* RIGHT SIDEBAR TOGGLE TAB — aligns with Oracle Predictions section */}
|
||||
{/* RIGHT SIDEBAR TOGGLE TAB */}
|
||||
<motion.div
|
||||
className="absolute right-0 top-[12.5rem] z-[201] pointer-events-auto hud-zone"
|
||||
animate={{ x: rightOpen ? -344 : 0 }}
|
||||
animate={{ x: rightOpen ? -424 : 0 }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 250 }}
|
||||
>
|
||||
<button
|
||||
@@ -756,8 +614,8 @@ export default function Dashboard() {
|
||||
|
||||
{/* RIGHT HUD CONTAINER — slides off right edge when hidden */}
|
||||
<motion.div
|
||||
className="absolute right-6 top-24 bottom-9 w-80 flex flex-col gap-4 z-[200] pointer-events-auto overflow-y-auto styled-scrollbar pr-2 pl-2 hud-zone"
|
||||
animate={{ x: rightOpen ? 0 : 360 }}
|
||||
className="absolute right-6 top-24 bottom-9 w-[400px] flex flex-col gap-4 z-[200] pointer-events-auto overflow-y-auto styled-scrollbar pr-2 pl-2 hud-zone"
|
||||
animate={{ x: rightOpen ? 0 : 440 }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 250 }}
|
||||
>
|
||||
<TopRightControls
|
||||
@@ -788,10 +646,10 @@ export default function Dashboard() {
|
||||
|
||||
{/* GLOBAL TICKER REPLACES MARKETS PANEL - RENDERED OUTSIDE THIS DIV */}
|
||||
|
||||
{/* ORACLE PREDICTIONS */}
|
||||
{/* EVENT TIMELINE */}
|
||||
<div className={`flex-shrink-0 ${rightFocusedPanel && rightFocusedPanel !== 'predictions' ? 'hidden' : ''}`}>
|
||||
<ErrorBoundary name="PredictionsPanel">
|
||||
<PredictionsPanel />
|
||||
<ErrorBoundary name="TimelinePanel">
|
||||
<TimelinePanel />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
@@ -812,9 +670,14 @@ export default function Dashboard() {
|
||||
selectedEntity={selectedEntity}
|
||||
regionDossier={regionDossier}
|
||||
regionDossierLoading={regionDossierLoading}
|
||||
onArticleClick={(idx, lat, lng) => {
|
||||
onArticleClick={(idx, lat, lng, title) => {
|
||||
if (lat !== undefined && lng !== undefined) {
|
||||
setFlyToLocation({ lat, lng, ts: Date.now() });
|
||||
// Also highlight the corresponding map alert
|
||||
if (title) {
|
||||
const alertKey = `${title}|${lat},${lng}`;
|
||||
setSelectedEntity({ id: alertKey, type: 'news' });
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -837,15 +700,15 @@ export default function Dashboard() {
|
||||
/>
|
||||
|
||||
<div
|
||||
className="bg-[#0a0a0a]/90 border border-cyan-900/40 px-5 py-1.5 flex items-center gap-5 border-b-2 border-b-cyan-800 cursor-pointer backdrop-blur-sm"
|
||||
className="bg-[#0a0a0a]/90 border border-cyan-900/40 px-6 py-2 flex items-center gap-6 border-b-2 border-b-cyan-800 cursor-pointer backdrop-blur-sm"
|
||||
onClick={cycleStyle}
|
||||
>
|
||||
{/* Coordinates */}
|
||||
<div className="flex flex-col items-center min-w-[120px]">
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
<div className="flex flex-col items-center min-w-[140px]">
|
||||
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
COORDINATES
|
||||
</div>
|
||||
<div className="text-[11px] text-cyan-400 font-mono font-bold tracking-wide">
|
||||
<div className="text-[14px] text-cyan-400 font-mono font-bold tracking-wide">
|
||||
{mouseCoords
|
||||
? `${mouseCoords.lat.toFixed(4)}, ${mouseCoords.lng.toFixed(4)}`
|
||||
: '0.0000, 0.0000'}
|
||||
@@ -856,11 +719,11 @@ export default function Dashboard() {
|
||||
<div className="w-px h-6 bg-[var(--border-primary)]" />
|
||||
|
||||
{/* Location name */}
|
||||
<div className="flex flex-col items-center min-w-[160px] max-w-[280px]">
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
<div className="flex flex-col items-center min-w-[180px] max-w-[320px]">
|
||||
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
LOCATION
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--text-secondary)] font-mono truncate max-w-[280px]">
|
||||
<div className="text-[13px] text-[var(--text-secondary)] font-mono truncate max-w-[320px]">
|
||||
{locationLabel || 'Hover over map...'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -870,10 +733,10 @@ export default function Dashboard() {
|
||||
|
||||
{/* Style preset (compact) */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
STYLE
|
||||
</div>
|
||||
<div className="text-[11px] text-cyan-400 font-mono font-bold">
|
||||
<div className="text-[14px] text-cyan-400 font-mono font-bold">
|
||||
{activeStyle}
|
||||
</div>
|
||||
</div>
|
||||
@@ -889,11 +752,11 @@ export default function Dashboard() {
|
||||
className="flex flex-col items-center"
|
||||
title={`Kp Index: ${sw?.kp_index ?? 'N/A'}`}
|
||||
>
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
<div className="text-[10px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
||||
SOLAR
|
||||
</div>
|
||||
<div
|
||||
className={`text-[11px] font-mono font-bold ${
|
||||
className={`text-[14px] font-mono font-bold ${
|
||||
(sw?.kp_index ?? 0) >= 5
|
||||
? 'text-red-400'
|
||||
: (sw?.kp_index ?? 0) >= 4
|
||||
@@ -906,6 +769,20 @@ export default function Dashboard() {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-6 bg-[var(--border-primary)]" />
|
||||
|
||||
{/* Feed Health */}
|
||||
<div className="flex items-center gap-3">
|
||||
{feedHealth.map((f) => (
|
||||
<div key={f.label} className="flex items-center gap-1 text-[10px] font-mono tracking-wider">
|
||||
<span className={`feed-dot feed-dot-${f.status}`} />
|
||||
<span className="text-[var(--text-muted)]">{f.label}</span>
|
||||
<span className="text-cyan-400 font-bold">{f.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -953,6 +830,15 @@ export default function Dashboard() {
|
||||
style={{ backgroundSize: '100% 4px' }}
|
||||
></div>
|
||||
|
||||
{/* WATCHLIST WIDGET */}
|
||||
<WatchlistWidget
|
||||
items={watchlistItems}
|
||||
onRemove={removeFromWatchlist}
|
||||
onClear={clearWatchlist}
|
||||
onFlyTo={handleFlyTo}
|
||||
/>
|
||||
|
||||
|
||||
{/* SETTINGS PANEL */}
|
||||
<ErrorBoundary name="SettingsPanel">
|
||||
<SettingsPanel isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
@@ -963,6 +849,16 @@ export default function Dashboard() {
|
||||
<MapLegend isOpen={legendOpen} onClose={() => setLegendOpen(false)} />
|
||||
</ErrorBoundary>
|
||||
|
||||
{/* KEYBOARD SHORTCUTS OVERLAY */}
|
||||
<KeyboardShortcutsOverlay isOpen={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||
|
||||
{/* ALERT TOAST NOTIFICATIONS */}
|
||||
<AlertToast
|
||||
toasts={toasts}
|
||||
onDismiss={dismissToast}
|
||||
onFlyTo={handleFlyTo}
|
||||
/>
|
||||
|
||||
{/* ONBOARDING MODAL */}
|
||||
{showOnboarding && (
|
||||
<OnboardingModal
|
||||
@@ -979,91 +875,21 @@ export default function Dashboard() {
|
||||
<ChangelogModal onClose={() => setShowChangelog(false)} />
|
||||
)}
|
||||
|
||||
{/* SENTINEL HUB — first-time info modal */}
|
||||
{/* SENTINEL HUB — first-time info modal (extracted to SentinelInfoModal.tsx) */}
|
||||
{showSentinelInfo && (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/90"
|
||||
onClick={() => setShowSentinelInfo(false)}
|
||||
/>
|
||||
<div className="relative z-[10001] w-[520px] max-h-[80vh] bg-[var(--bg-secondary)] border border-purple-500/30 shadow-2xl shadow-purple-900/20 overflow-y-auto styled-scrollbar">
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold tracking-wider text-purple-300 font-mono">
|
||||
SENTINEL HUB IMAGERY
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowSentinelInfo(false)}
|
||||
className="text-[var(--text-muted)] hover:text-white transition-colors text-xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<SentinelInfoModal onClose={() => setShowSentinelInfo(false)} />
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
You now have access to ESA Sentinel-2 satellite imagery directly on the map.
|
||||
This uses the Copernicus Data Space Ecosystem with your own credentials.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[10px] font-mono text-purple-400 tracking-widest">AVAILABLE LAYERS</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ name: 'True Color', desc: 'Natural RGB — see terrain, cities, water' },
|
||||
{ name: 'False Color IR', desc: 'Near-infrared — vegetation in red' },
|
||||
{ name: 'NDVI', desc: 'Vegetation health index (green = healthy)' },
|
||||
{ name: 'Moisture Index', desc: 'Soil & vegetation moisture levels' },
|
||||
].map((l) => (
|
||||
<div key={l.name} className="p-2 border border-purple-900/30 bg-purple-950/10">
|
||||
<div className="text-[10px] font-mono text-white">{l.name}</div>
|
||||
<div className="text-[9px] text-[var(--text-muted)]">{l.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[10px] font-mono text-purple-400 tracking-widest">USAGE LIMITS (FREE TIER)</h3>
|
||||
<div className="p-3 border border-[var(--border-primary)] bg-[var(--bg-primary)]/40 space-y-1.5">
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-[var(--text-muted)]">Monthly budget</span>
|
||||
<span className="text-purple-300">10,000 requests</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-[var(--text-muted)]">Cost per tile</span>
|
||||
<span className="text-purple-300">0.25 PU (256×256px)</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-[var(--text-muted)]">~Viewport loads/month</span>
|
||||
<span className="text-purple-300">~500 (20 tiles each)</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-[var(--text-muted)]">Empty tiles</span>
|
||||
<span className="text-green-400">FREE (no data = no charge)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[10px] font-mono text-purple-400 tracking-widest">HOW IT WORKS</h3>
|
||||
<ul className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed space-y-1 list-disc list-inside">
|
||||
<li>Sentinel-2 revisits every ~5 days — not every location has data every day</li>
|
||||
<li>The date slider picks the end of a time window; zoomed out uses wider windows</li>
|
||||
<li>Black patches = no satellite pass on that date range (normal)</li>
|
||||
<li>Best results at zoom 8-14 — closer = sharper imagery (10m resolution)</li>
|
||||
<li>Cloud filter auto-skips tiles with {'>'} 30% cloud cover</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowSentinelInfo(false)}
|
||||
className="w-full py-2.5 bg-purple-500/20 border border-purple-500/40 text-purple-300 hover:bg-purple-500/30 transition-colors text-[11px] font-mono tracking-wider"
|
||||
>
|
||||
GOT IT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* SAR AOI EDITOR — portals to document.body internally */}
|
||||
{(sarAoiEditorOpen || sarAoiDropMode) && (
|
||||
<SarAoiEditorModal
|
||||
onClose={() => { setSarAoiEditorOpen(false); setSarAoiDropMode(false); }}
|
||||
onRequestMapPick={() => { setSarAoiEditorOpen(false); setSarAoiDropMode(true); }}
|
||||
pickedCoords={sarAoiDroppedCoords}
|
||||
onPickConsumed={() => setSarAoiDroppedCoords(null)}
|
||||
onAoiListChanged={() => setSarAoiListVersion((v) => v + 1)}
|
||||
dropModeActive={sarAoiDropMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* MESH TERMINAL */}
|
||||
@@ -1081,13 +907,10 @@ export default function Dashboard() {
|
||||
onClose={() => {
|
||||
setInfonetOpen(false);
|
||||
// Shut down Wormhole when the terminal closes so it doesn't stay running
|
||||
fetchWormholeState(false)
|
||||
.then((s) => {
|
||||
if (s?.ready || s?.running) return leaveWormhole();
|
||||
})
|
||||
.catch(() => {});
|
||||
void teardownWormholeOnClose(fetchWormholeState, leaveWormhole);
|
||||
}}
|
||||
onOpenLiveGate={openLiveGateFromShell}
|
||||
onOpenDeadDrop={openDeadDropFromShell}
|
||||
/>
|
||||
|
||||
{/* BACKEND DISCONNECTED BANNER */}
|
||||
@@ -1099,9 +922,9 @@ export default function Dashboard() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* BOTTOM TICKER TOGGLE TAB — moved to right to avoid Shodan overlap */}
|
||||
{/* BOTTOM TICKER TOGGLE TAB — moved to center-right to avoid panel overlap */}
|
||||
<motion.div
|
||||
className={`absolute bottom-0 right-[22rem] z-[8001] pointer-events-auto hud-zone transition-opacity duration-300 ${tickerOpen ? 'opacity-100' : 'opacity-40 hover:opacity-100'}`}
|
||||
className={`absolute bottom-0 right-[28rem] z-[8001] pointer-events-auto hud-zone transition-opacity duration-300 ${tickerOpen ? 'opacity-100' : 'opacity-40 hover:opacity-100'}`}
|
||||
animate={{ y: tickerOpen ? -28 : 0 }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 250 }}
|
||||
>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -269,7 +269,7 @@ export default function AdvancedFilterModal({
|
||||
>
|
||||
{field.label}
|
||||
{count > 0 && (
|
||||
<span className={`ml-1.5 text-[8px] ${c.text} bg-black/40 px-1`}>
|
||||
<span className={`ml-1.5 text-[11px] ${c.text} bg-black/40 px-1`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
@@ -301,7 +301,7 @@ export default function AdvancedFilterModal({
|
||||
})}
|
||||
<button
|
||||
onClick={() => clearField(activeTab)}
|
||||
className="text-[8px] text-red-400/70 hover:text-red-300 tracking-widest ml-1"
|
||||
className="text-[11px] text-red-400/70 hover:text-red-300 tracking-widest ml-1"
|
||||
>
|
||||
CLEAR
|
||||
</button>
|
||||
@@ -335,10 +335,10 @@ export default function AdvancedFilterModal({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between mt-1.5">
|
||||
<span className="text-[8px] text-[var(--text-muted)] tracking-widest">
|
||||
<span className="text-[11px] text-[var(--text-muted)] tracking-widest">
|
||||
{filteredOptions.length} AVAILABLE
|
||||
</span>
|
||||
<span className="text-[8px] text-[var(--text-muted)] tracking-widest">
|
||||
<span className="text-[11px] text-[var(--text-muted)] tracking-widest">
|
||||
{draft[activeTab]?.size || 0} SELECTED
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import type { ToastItem } from '@/hooks/useAlertToasts';
|
||||
|
||||
function getRiskColor(score: number): string {
|
||||
if (score >= 9) return '#ef4444';
|
||||
if (score >= 7) return '#f97316';
|
||||
if (score >= 4) return '#eab308';
|
||||
return '#22d3ee';
|
||||
}
|
||||
|
||||
function getRiskLabel(score: number): string {
|
||||
if (score >= 9) return 'CRITICAL';
|
||||
if (score >= 7) return 'HIGH';
|
||||
return 'ELEVATED';
|
||||
}
|
||||
|
||||
export default function AlertToast({
|
||||
toasts,
|
||||
onDismiss,
|
||||
onFlyTo,
|
||||
}: {
|
||||
toasts: ToastItem[];
|
||||
onDismiss: (id: string) => void;
|
||||
onFlyTo?: (lat: number, lng: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed top-16 right-[440px] z-[9500] flex flex-col gap-2 pointer-events-none max-w-[380px]">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{toasts.map((toast) => {
|
||||
const color = getRiskColor(toast.risk_score);
|
||||
const label = getRiskLabel(toast.risk_score);
|
||||
return (
|
||||
<motion.div
|
||||
key={toast.id}
|
||||
layout
|
||||
initial={{ opacity: 0, x: 100, scale: 0.9 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 100, scale: 0.9 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="pointer-events-auto cursor-pointer"
|
||||
onClick={() => {
|
||||
if (onFlyTo && toast.lat && toast.lng) {
|
||||
onFlyTo(toast.lat, toast.lng);
|
||||
}
|
||||
onDismiss(toast.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative bg-[rgba(5,5,5,0.96)] backdrop-blur-sm rounded-sm overflow-hidden font-mono"
|
||||
style={{
|
||||
borderLeft: `3px solid ${color}`,
|
||||
boxShadow: `0 0 20px ${color}40, 0 4px 12px rgba(0,0,0,0.5)`,
|
||||
}}
|
||||
>
|
||||
{/* Progress bar */}
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 h-[2px]"
|
||||
style={{ background: color }}
|
||||
initial={{ width: '100%' }}
|
||||
animate={{ width: '0%' }}
|
||||
transition={{ duration: 5, ease: 'linear' }}
|
||||
/>
|
||||
|
||||
<div className="p-3 pr-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span
|
||||
className="text-[9px] font-bold tracking-[0.2em] px-1.5 py-0.5 rounded-sm"
|
||||
style={{
|
||||
background: `${color}20`,
|
||||
color: color,
|
||||
border: `1px solid ${color}40`,
|
||||
}}
|
||||
>
|
||||
⚠ {label}
|
||||
</span>
|
||||
<span className="text-[9px] text-[var(--text-muted)] tracking-wider uppercase">
|
||||
LVL {toast.risk_score}/10
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
className="text-[11px] text-[var(--text-primary)] leading-tight mb-1"
|
||||
style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}
|
||||
>
|
||||
{toast.title}
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div className="text-[9px] text-[var(--text-muted)] tracking-wider uppercase">
|
||||
{toast.source}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
className="absolute top-2 right-2 text-[var(--text-muted)] hover:text-white transition-colors text-xs font-bold"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss(toast.id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,88 +5,115 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
X,
|
||||
Terminal,
|
||||
Radio,
|
||||
Camera,
|
||||
Search,
|
||||
TrainFront,
|
||||
Globe,
|
||||
Bot,
|
||||
Network,
|
||||
Scale,
|
||||
KeyRound,
|
||||
Cpu,
|
||||
Layers,
|
||||
GitBranch,
|
||||
Shield,
|
||||
Plane,
|
||||
Clock,
|
||||
Satellite,
|
||||
Bug,
|
||||
Heart,
|
||||
} from 'lucide-react';
|
||||
|
||||
const CURRENT_VERSION = '0.9.6';
|
||||
const CURRENT_VERSION = '0.9.7';
|
||||
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
|
||||
const RELEASE_TITLE = 'InfoNet Experimental Testnet — Decentralized Intelligence Experiment';
|
||||
const RELEASE_TITLE = 'Agentic AI Channel + InfoNet Decentralized Intelligence';
|
||||
|
||||
const HEADLINE_FEATURE = {
|
||||
icon: <Terminal size={20} className="text-cyan-400" />,
|
||||
title: 'InfoNet Experimental Testnet is Live',
|
||||
subtitle: 'The first decentralized intelligence mesh built directly into an OSINT platform. This is an experimental testnet — NOT a privacy tool.',
|
||||
details: [
|
||||
'A global, obfuscated message relay running inside ShadowBroker. Anyone with the dashboard can transmit and receive on the InfoNet — no accounts, no signup, no identity required.',
|
||||
'Messages pass through a Wormhole relay layer with gate personas, canonical payload signing, and message obfuscation. Transport is obfuscated to a degree, but this is NOT private communication. Do not transmit anything you would not say in public. End-to-end encryption is being developed but is not yet implemented.',
|
||||
'Dead Drop inbox for peer-to-peer message exchange. Mesh Terminal CLI for power users. Gate persona system for pseudonymous identity. Double-ratchet DM scaffolding in progress.',
|
||||
'Nothing like this has existed in an OSINT tool before. This is an open experiment — jump on the testnet, explore the protocol, and help shape what decentralized intelligence looks like.',
|
||||
],
|
||||
callToAction: 'OPEN MESH CHAT \u2192 MESH TAB \u2192 START TRANSMITTING',
|
||||
};
|
||||
const HEADLINE_FEATURES = [
|
||||
{
|
||||
icon: <Bot size={20} className="text-purple-400" />,
|
||||
accent: 'purple' as const,
|
||||
title: 'Agentic AI Channel — supports OpenClaw and any HMAC-signing agent',
|
||||
subtitle: 'ShadowBroker now exposes a signed agent command channel. Bring your own agent (OpenClaw, Claude Code, GPT, LangChain, or a custom client) and drive the dashboard from any LLM that speaks the protocol.',
|
||||
details: [
|
||||
'A signed command channel (POST /api/ai/channel/command) plus a batched concurrent-execution endpoint (up to 20 tool calls per round-trip via /api/ai/channel/batch). Agents query flights, ships, SIGINT, news, and intel layers; reason over the live mesh; and run market or threat analyses without a human in the loop.',
|
||||
'HMAC-SHA256 request signing with timestamp + nonce replay protection. Tier-gated access (restricted vs full) governs which read and write commands the agent can invoke. Every call is auditable through the channel log.',
|
||||
'ShadowBroker does not bundle an LLM, an agent runtime, or model weights — it ships the protocol. Any agent that signs requests with the documented HMAC contract can connect. OpenClaw is the reference implementation.',
|
||||
],
|
||||
callToAction: 'CONNECT YOUR AGENT \u2192 /API/AI/CHANNEL/COMMAND',
|
||||
},
|
||||
{
|
||||
icon: <Network size={20} className="text-cyan-400" />,
|
||||
accent: 'cyan' as const,
|
||||
title: 'InfoNet Testnet \u2014 Framework, Privacy, and a Path to Decentralized Intelligence',
|
||||
subtitle: 'The testnet now ships its full governance economy and the runway for a privacy-preserving decentralized intelligence platform.',
|
||||
details: [
|
||||
'Sovereign Shell views: petitions (governance DSL covers parameter updates and feature toggles), upgrade-hash voting (80% supermajority, 67% Heavy-Node activation), evidence submission, dispute markets, gate suspension and shutdown, and bootstrap eligible-node-one-vote. Every write action is a clickable form with verbatim diagnostics on rejection.',
|
||||
'Privacy primitive runway: locked Protocol contracts for ring signatures, stealth addresses, shielded balances, and DEX matching. The privacy-core Rust crate is the integration target. Function Keys (anonymous citizenship proof) ship 5 of 6 pieces; only blind-signature issuance waits on a primitive decision.',
|
||||
'Backbone: two-tier event state with epoch finality, identity rotation, progressive penalties, ramp milestones, and constitutional invariants enforced via MappingProxyType. Sprint 11+ wires the cryptographic primitives into the locked Protocols.',
|
||||
'Still an experimental testnet \u2014 no privacy guarantee yet. Treat all channels as public until E2E and the privacy primitives ship.',
|
||||
],
|
||||
callToAction: 'OPEN SOVEREIGN SHELL \u2192 PETITIONS \u2022 UPGRADES \u2022 GATES',
|
||||
},
|
||||
];
|
||||
|
||||
const NEW_FEATURES = [
|
||||
{
|
||||
icon: <Radio size={18} className="text-amber-400" />,
|
||||
title: 'Meshtastic + APRS Radio Integration',
|
||||
desc: 'Live Meshtastic mesh radio nodes plotted worldwide via MQTT. APRS amateur radio positioning via APRS-IS TCP feed. Both integrated into Mesh Chat and the SIGINT grid. Note: Mesh radio is NOT private — RF transmissions are public by nature.',
|
||||
color: 'amber',
|
||||
icon: <Cpu size={18} className="text-purple-400" />,
|
||||
title: 'AI Batch Command Channel',
|
||||
desc: 'POST up to 20 tool calls in a single HTTP round-trip; the backend executes them concurrently and returns a fan-out result map. Cuts agent latency by an order of magnitude over sequential calls.',
|
||||
},
|
||||
{
|
||||
icon: <Scale size={18} className="text-amber-400" />,
|
||||
title: 'Governance DSL — Petition-Driven Parameter Changes',
|
||||
desc: 'Type-safe payload executor for UPDATE_PARAM, BATCH_UPDATE_PARAMS, ENABLE_FEATURE, and DISABLE_FEATURE petitions. Tunable knobs change on-chain via a vote — no code deploys required.',
|
||||
},
|
||||
{
|
||||
icon: <GitBranch size={18} className="text-purple-400" />,
|
||||
title: 'Upgrade-Hash Governance',
|
||||
desc: 'Protocol upgrades that need new logic (not just parameter changes) vote on a SHA-256 hash of the verified release. 80% supermajority, 40% quorum, 67% Heavy-Node activation. Lifecycle: signatures, voting, challenge window, awaiting readiness, activated.',
|
||||
},
|
||||
{
|
||||
icon: <KeyRound size={18} className="text-purple-400" />,
|
||||
title: 'Function Keys — Anonymous Citizenship Proof',
|
||||
desc: 'A citizen proves "I am an Infonet citizen" without revealing their Infonet identity. 5 of 6 pieces shipped: nullifiers, challenge-response, two-phase commit receipts, enumerated denial codes, batched settlement. Issuance via blind signatures waits on a primitive decision.',
|
||||
},
|
||||
{
|
||||
icon: <Shield size={18} className="text-cyan-400" />,
|
||||
title: 'Privacy Primitive Runway',
|
||||
desc: 'Locked Protocol contracts in services/infonet/privacy/contracts.py for ring signatures, stealth addresses, Pedersen commitments, range proofs, and DEX matching. The privacy-core Rust crate is the integration target — no caller of the privacy module needs to know which scheme is active.',
|
||||
},
|
||||
{
|
||||
icon: <Layers size={18} className="text-blue-400" />,
|
||||
title: 'Two-Tier State + Epoch Finality',
|
||||
desc: 'Tier 1 events propagate CRDT-style for low latency; Tier 2 events require epoch finality before they can be acted on. Identity rotation, progressive penalties, ramp milestones, and constitutional invariants are enforced via MappingProxyType.',
|
||||
},
|
||||
{
|
||||
icon: <Terminal size={18} className="text-cyan-400" />,
|
||||
title: 'Mesh Terminal',
|
||||
desc: 'Built-in command-line interface. Send messages, DMs, run market commands, inspect gate state. Draggable panel, minimizes to the top bar. Type "help" to see everything.',
|
||||
color: 'cyan',
|
||||
title: 'Sovereign Shell Write Surface',
|
||||
desc: 'PetitionsView, UpgradeView, ResolutionView, GateShutdownView, BootstrapView, and FunctionKeyView each expose every Sprint 4-8 + 10 write action as a clickable form. Adaptive polling tightens to 8 seconds during active voting/challenge phases.',
|
||||
},
|
||||
{
|
||||
icon: <Search size={18} className="text-green-400" />,
|
||||
title: 'Shodan Device Search',
|
||||
desc: 'Query Shodan directly from ShadowBroker. Search internet-connected devices by keyword, CVE, or port — results plotted as a live overlay on the map with configurable marker style.',
|
||||
color: 'green',
|
||||
icon: <Clock size={18} className="text-pink-400" />,
|
||||
title: 'Time Machine — Snapshot Playback',
|
||||
desc: 'Scrub backward through saved telemetry. Live polling pauses on entry to snapshot mode, the map redraws from the recorded snapshot, and moving entities interpolate between recorded frames. Hourly index lets you jump to any captured timestamp; pressing Live restores the current feed instantly.',
|
||||
},
|
||||
{
|
||||
icon: <Camera size={18} className="text-emerald-400" />,
|
||||
title: 'CCTV Mesh Expanded — 12 Sources, 11,000+ Cameras',
|
||||
desc: 'Massive expansion: added Spain (DGT national + Madrid city), California (12 Caltrans districts), Washington State, Georgia, Illinois, Michigan, and Windy Webcams. Now covers 6 countries. Enabled by default.',
|
||||
color: 'emerald',
|
||||
},
|
||||
{
|
||||
icon: <TrainFront size={18} className="text-blue-400" />,
|
||||
title: 'Train Tracking (Amtrak + European Rail)',
|
||||
desc: 'Real-time Amtrak train positions across the US and European rail via DigiTraffic. Speed, heading, route, and status for every train on the network.',
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
icon: <Globe size={18} className="text-purple-400" />,
|
||||
title: '8 New Intelligence Layers',
|
||||
desc: 'Volcanoes (Smithsonian), air quality PM2.5 (OpenAQ), severe weather alerts, fishing activity (Global Fishing Watch), military bases, 35K+ power plants, SatNOGS ground stations, TinyGS LoRa satellites, VIIRS nightlights.',
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
icon: <Shield size={18} className="text-yellow-400" />,
|
||||
title: 'Sentinel Hub Imagery + Desktop Shell Scaffold',
|
||||
desc: 'Copernicus CDSE satellite imagery via Sentinel Hub Process API with OAuth2 token flow. Desktop-native control routing scaffold (pre-Tauri) with session profiles and audit trail.',
|
||||
color: 'yellow',
|
||||
icon: <Satellite size={18} className="text-orange-400" />,
|
||||
title: 'SAR Satellite Telemetry — ASF, OPERA, Copernicus',
|
||||
desc: 'New SAR (Synthetic Aperture Radar) layer. Mode A (default-on) pulls free catalog metadata from the Alaska Satellite Facility — no account required. Mode B (two-step opt-in) ingests pre-processed ground-change anomalies from NASA OPERA, Copernicus EGMS, GFM, EMS, and UNOSAT — deformation, flood, and damage assessments. Integrates with OpenClaw so agents can read and act on SAR anomalies; broadcasts default to private-tier transport (Tor / RNS).',
|
||||
},
|
||||
];
|
||||
|
||||
const BUG_FIXES = [
|
||||
'CCTV auto-seed fix — partial DB (4 of 12 sources) no longer silently skips the other 8 ingestors on startup',
|
||||
'SQLite threading fix — CCTV ingestors no longer share connections across threads',
|
||||
'CCTV layer now ON by default and participates in the All On/Off global toggle',
|
||||
'KiwiSDR, FIRMS fires, internet outages, data centers all switched to ON by default',
|
||||
'Terminal minimized tab repositioned to top-center with proper icon (no more phantom cursor)',
|
||||
'Mesh Chat defaults to MESH tab on startup instead of locked INFONET gate',
|
||||
'Sovereign Shell adaptive polling — voting and challenge windows refresh every 8 seconds while active, every 30 to 60 seconds when idle. Voting feels live without a websocket layer.',
|
||||
'Per-row write actions (petitions, upgrades, disputes) hold isolated submission state so concurrent forms no longer share a single in-flight slot.',
|
||||
'Verbatim diagnostic surfacing on every write button. The backend reason text is always shown on rejection — no opaque "denied" toasts.',
|
||||
'Evidence submission canonicalization matches Python repr() exactly, so client-side SHA-256 hashes round-trip cleanly through the chain.',
|
||||
'Function Keys copy is context-agnostic — citizenship proof is described abstractly, not tied to a specific use case.',
|
||||
'Post-cutover legacy mesh files (mesh_schema.py, mesh_signed_events.py, mesh_hashchain.py) hash-verified against the recorded baseline; the chain extension hook stays surgical.',
|
||||
];
|
||||
|
||||
const CONTRIBUTORS = [
|
||||
{
|
||||
name: '@Alienmajik',
|
||||
desc: 'Raspberry Pi 5 support — ARM64 packaging, headless deployment notes, and runtime tuning for Pi-class hardware',
|
||||
},
|
||||
{
|
||||
name: '@wa1id',
|
||||
desc: 'CCTV ingestion fix — fresh SQLite connections per ingest, persistent DB path, startup hydration, cluster clickability',
|
||||
@@ -236,55 +263,95 @@ const ChangelogModal = React.memo(function ChangelogModal({ onClose }: Changelog
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar p-5 space-y-5">
|
||||
{/* === HEADLINE: InfoNet Testnet === */}
|
||||
<div className="border border-cyan-500/30 bg-cyan-950/20 p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 border border-cyan-500/40 bg-cyan-500/10 flex items-center justify-center flex-shrink-0">
|
||||
{HEADLINE_FEATURE.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-mono text-cyan-300 font-bold tracking-wide">
|
||||
{HEADLINE_FEATURE.title}
|
||||
</div>
|
||||
<div className="text-xs font-mono text-cyan-500/80 mt-0.5">
|
||||
{HEADLINE_FEATURE.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* === HEADLINE PAIR: OpenClaw API + InfoNet === */}
|
||||
{HEADLINE_FEATURES.map((h, idx) => {
|
||||
const isPurple = h.accent === 'purple';
|
||||
const cardClass = isPurple
|
||||
? 'border border-purple-500/30 bg-purple-950/20 p-4 space-y-3'
|
||||
: 'border border-cyan-500/30 bg-cyan-950/20 p-4 space-y-3';
|
||||
const iconWrapClass = isPurple
|
||||
? 'w-9 h-9 border border-purple-500/40 bg-purple-500/10 flex items-center justify-center flex-shrink-0'
|
||||
: 'w-9 h-9 border border-cyan-500/40 bg-cyan-500/10 flex items-center justify-center flex-shrink-0';
|
||||
const titleClass = isPurple
|
||||
? 'text-sm font-mono text-purple-300 font-bold tracking-wide'
|
||||
: 'text-sm font-mono text-cyan-300 font-bold tracking-wide';
|
||||
const subtitleClass = isPurple
|
||||
? 'text-xs font-mono text-purple-500/80 mt-0.5'
|
||||
: 'text-xs font-mono text-cyan-500/80 mt-0.5';
|
||||
const ctaClass = isPurple
|
||||
? 'text-[11px] font-mono text-purple-400 tracking-[0.25em] font-bold'
|
||||
: 'text-[11px] font-mono text-cyan-400 tracking-[0.25em] font-bold';
|
||||
|
||||
<div className="space-y-2">
|
||||
{HEADLINE_FEATURE.details.map((para, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className="text-xs font-mono text-[var(--text-secondary)] leading-relaxed"
|
||||
return (
|
||||
<div key={idx} className={cardClass}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={iconWrapClass}>{h.icon}</div>
|
||||
<div>
|
||||
<div className={titleClass}>{h.title}</div>
|
||||
<div className={subtitleClass}>{h.subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{h.details.map((para, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className="text-xs font-mono text-[var(--text-secondary)] leading-relaxed"
|
||||
>
|
||||
{para}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!isPurple && (
|
||||
<div className="flex items-start gap-2 p-2.5 border border-red-500/30 bg-red-950/20">
|
||||
<span className="text-red-400 text-xs mt-0.5 flex-shrink-0 font-bold">!!</span>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[11px] font-mono text-red-400/90 leading-relaxed block font-bold">
|
||||
EXPERIMENTAL TESTNET — NO PRIVACY GUARANTEE
|
||||
</span>
|
||||
<span className="text-[11px] font-mono text-amber-400/80 leading-relaxed block">
|
||||
InfoNet messages are obfuscated but NOT encrypted end-to-end. The Mesh
|
||||
network (Meshtastic/APRS) is NOT private — radio transmissions are
|
||||
inherently public. The privacy primitive contracts are scaffolded but not
|
||||
yet wired. Treat all channels as open and public for now.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center pt-1">
|
||||
<span className={ctaClass}>{h.callToAction}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* === Required-config callout: OpenSky API === */}
|
||||
<div className="border border-amber-500/40 bg-amber-950/20 p-3 flex items-start gap-3">
|
||||
<Plane size={18} className="text-amber-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-mono text-amber-300 font-bold tracking-wide uppercase">
|
||||
Required: OpenSky API credentials for airplane telemetry
|
||||
</div>
|
||||
<div className="text-xs font-mono text-amber-200/80 leading-relaxed">
|
||||
Airplane telemetry now requires an OpenSky Network OAuth2 client. Set{' '}
|
||||
<span className="text-amber-100 font-bold">OPENSKY_CLIENT_ID</span> and{' '}
|
||||
<span className="text-amber-100 font-bold">OPENSKY_CLIENT_SECRET</span> in your{' '}
|
||||
<span className="text-amber-100 font-bold">.env</span>. Free registration:{' '}
|
||||
<a
|
||||
href="https://opensky-network.org/index.php?option=com_users&view=registration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-amber-100 font-bold underline underline-offset-2 hover:text-amber-50"
|
||||
>
|
||||
{para}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Testnet disclaimer */}
|
||||
<div className="flex items-start gap-2 p-2.5 border border-red-500/30 bg-red-950/20">
|
||||
<span className="text-red-400 text-xs mt-0.5 flex-shrink-0 font-bold">!!</span>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[11px] font-mono text-red-400/90 leading-relaxed block font-bold">
|
||||
EXPERIMENTAL TESTNET — NO PRIVACY GUARANTEE
|
||||
</span>
|
||||
<span className="text-[11px] font-mono text-amber-400/80 leading-relaxed block">
|
||||
InfoNet messages are obfuscated but NOT encrypted end-to-end. The Mesh network
|
||||
(Meshtastic/APRS) is NOT private — radio transmissions are inherently
|
||||
public. Do not send anything sensitive on any channel. Privacy and E2E encryption
|
||||
are actively being developed. Treat all channels as open and public for now.
|
||||
</span>
|
||||
opensky-network.org/register
|
||||
</a>
|
||||
. Without these the flights layer falls back to ADS-B-only coverage with
|
||||
significant gaps in Africa, Asia, and Latin America, and the startup environment
|
||||
check will surface a critical warning.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="text-center pt-1">
|
||||
<span className="text-[11px] font-mono text-cyan-400 tracking-[0.25em] font-bold">
|
||||
{HEADLINE_FEATURE.callToAction}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* === Other New Features === */}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Minus,
|
||||
Plus,
|
||||
Filter,
|
||||
Plane,
|
||||
Shield,
|
||||
@@ -300,27 +300,29 @@ const FilterPanel = React.memo(function FilterPanel({ activeFilters, setActiveFi
|
||||
initial={{ y: -30, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="w-full bg-[#0a0a0a]/90 backdrop-blur-sm border border-cyan-900/40 z-10 flex flex-col font-mono text-sm pointer-events-auto flex-shrink-0"
|
||||
className="w-full bg-[#0a0a0a]/90 backdrop-blur-sm border border-cyan-900/40 z-10 flex flex-col font-mono pointer-events-auto flex-shrink-0"
|
||||
>
|
||||
{/* Header Toggle */}
|
||||
<div
|
||||
className="flex justify-between items-center p-4 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
|
||||
className="flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-cyan-950/30 transition-colors border-b border-cyan-900/40"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={12} className="text-cyan-500" />
|
||||
<span className="text-[12px] text-[var(--text-muted)] font-mono tracking-widest">
|
||||
<Filter size={16} className="text-cyan-400" />
|
||||
<span className="text-[12px] text-cyan-400 font-mono tracking-widest font-bold">
|
||||
DATA FILTERS
|
||||
</span>
|
||||
{activeCount > 0 && (
|
||||
<span className="text-[10px] bg-cyan-500/20 text-cyan-400 px-1.5 py-0.5 rounded-sm font-mono">
|
||||
<span className="text-[11px] bg-cyan-500/20 text-cyan-400 px-1.5 py-0.5 font-mono">
|
||||
{activeCount} ACTIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
{isMinimized ? (
|
||||
<Plus size={16} className="text-cyan-400" />
|
||||
) : (
|
||||
<Minus size={16} className="text-cyan-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
@@ -356,7 +358,7 @@ const FilterPanel = React.memo(function FilterPanel({ activeFilters, setActiveFi
|
||||
</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className={`text-[8px] ${bgColors[section.color]} ${textColors[section.color]} px-1.5 py-0.5 rounded-sm`}
|
||||
className={`text-[11px] ${bgColors[section.color]} ${textColors[section.color]} px-1.5 py-0.5 rounded-sm`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
|
||||
@@ -183,6 +183,7 @@ const FindLocateBar = React.memo(function FindLocateBar({ onLocate, onFilter }:
|
||||
value={query}
|
||||
name="sb-locate-search"
|
||||
autoComplete="off"
|
||||
data-search-input
|
||||
placeholder="Search aircraft, person or vessel..."
|
||||
className="flex-1 bg-transparent text-[12px] text-[var(--text-secondary)] font-mono tracking-wider outline-none placeholder:text-slate-500"
|
||||
onChange={(e) => {
|
||||
@@ -227,19 +228,19 @@ const FindLocateBar = React.memo(function FindLocateBar({ onLocate, onFilter }:
|
||||
<div className="text-[10px] text-[var(--text-primary)] font-mono tracking-wide truncate">
|
||||
{r.label}
|
||||
</div>
|
||||
<div className="text-[8px] text-[var(--text-muted)] font-mono truncate">
|
||||
<div className="text-[11px] text-[var(--text-muted)] font-mono truncate">
|
||||
{r.sublabel}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-[7px] font-bold tracking-widest ${r.categoryColor} flex-shrink-0`}
|
||||
className={`text-[10px] font-bold tracking-widest ${r.categoryColor} flex-shrink-0`}
|
||||
>
|
||||
{r.category}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 border-t border-[var(--border-primary)] bg-[var(--bg-primary)]/50 text-[8px] text-[var(--text-muted)] font-mono tracking-widest">
|
||||
<div className="px-3 py-1.5 border-t border-[var(--border-primary)] bg-[var(--bg-primary)]/50 text-[11px] text-[var(--text-muted)] font-mono tracking-widest">
|
||||
{filtered.length} RESULT{filtered.length !== 1 ? 'S' : ''} — CLICK TO LOCATE
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function GlobalTicker() {
|
||||
<div className="absolute right-0 top-0 bottom-0 bg-gradient-to-l from-red-950/90 via-black/80 to-transparent w-[450px] z-10 flex items-center justify-end px-4 pointer-events-none">
|
||||
<div className="flex items-center gap-2 text-red-400 bg-red-950/50 px-2 pl-3 py-0.5 border border-red-500/30 rounded shadow-[0_0_10px_rgba(239,68,68,0.2)]">
|
||||
<AlertTriangle size={10} className="animate-pulse" />
|
||||
<span className="text-[8px] font-mono font-bold tracking-widest uppercase shadow-black drop-shadow-md">
|
||||
<span className="text-[11px] font-mono font-bold tracking-widest uppercase shadow-black drop-shadow-md">
|
||||
SYS WARN: FINNHUB API KEY MISSING — YAHOO FALLBACK ACTIVE (LIMITED)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { ArrowLeft, Send, MapPin, Loader2, Brain, Trash2, Sparkles, Link2, Copy, Check, X } from 'lucide-react';
|
||||
import { getBackendEndpoint } from '@/lib/backendEndpoint';
|
||||
|
||||
interface AIMessage {
|
||||
role: 'user' | 'ai' | 'system';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
pins?: { lat: number; lng: number; label: string }[];
|
||||
}
|
||||
|
||||
interface AIQueryViewProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const EXAMPLE_QUERIES = [
|
||||
'What military flights are active right now?',
|
||||
'Show me recent earthquakes over magnitude 4',
|
||||
'What ships are near the Taiwan Strait?',
|
||||
'Give me a threat level assessment',
|
||||
'What are the top prediction market movers?',
|
||||
'Are there any correlation alerts?',
|
||||
'Show me satellite imagery of Tehran',
|
||||
'Get news from Ukraine',
|
||||
'What SIGINT activity is happening?',
|
||||
'Place a pin on every military base near Denver',
|
||||
];
|
||||
|
||||
export default function AIQueryView({ onBack }: AIQueryViewProps) {
|
||||
const [messages, setMessages] = useState<AIMessage[]>([
|
||||
{
|
||||
role: 'system',
|
||||
content: `🌍📡 SHADOWBROKER AI CO-PILOT ONLINE
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Connected to ShadowBroker OSINT platform.
|
||||
I can query telemetry, place pins on the map,
|
||||
search satellite imagery, aggregate news,
|
||||
and access all 30+ data layers.
|
||||
|
||||
Type a question or command to get started.
|
||||
Use "help" to see capabilities.`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showConnect, setShowConnect] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const apiEndpoint = getBackendEndpoint();
|
||||
|
||||
const handleCopy = useCallback((text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const processQuery = useCallback(async (query: string) => {
|
||||
const lowerQuery = query.toLowerCase().trim();
|
||||
|
||||
// Handle built-in commands
|
||||
if (lowerQuery === 'help') {
|
||||
return {
|
||||
content: `🌍🔍 AVAILABLE COMMANDS:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
TELEMETRY QUERIES:
|
||||
• "military flights" — Active military aircraft
|
||||
• "ships" — Tracked vessels
|
||||
• "satellites" — Orbital assets
|
||||
• "earthquakes" — Recent seismic activity
|
||||
• "threat level" — Current threat assessment
|
||||
• "prediction markets" — Market consensus data
|
||||
• "sigint" — RF signal intelligence totals
|
||||
• "correlations" — Cross-layer alerts
|
||||
|
||||
INTELLIGENCE:
|
||||
• "report" — Full intelligence report
|
||||
• "summary" — Quick telemetry summary
|
||||
• "news summary" — AI news brief with top stories & trends
|
||||
• "correlations" — Explain cross-layer correlation alerts
|
||||
• "news [place]" — News near a location
|
||||
• "satellite images [place]" — Sentinel-2 imagery
|
||||
|
||||
PIN COMMANDS:
|
||||
• "pin [lat] [lng] [label]" — Place a pin
|
||||
• "clear pins" — Clear all AI pins
|
||||
• "list pins" — Show current pins
|
||||
|
||||
TIME MACHINE:
|
||||
• "snapshot" — Take a telemetry snapshot
|
||||
• "snapshots" — List available snapshots
|
||||
• "timemachine config" — View snapshot settings
|
||||
|
||||
SYSTEM:
|
||||
• "status" — AI system status
|
||||
• "clear" — Clear chat history
|
||||
• "help" — This message`,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerQuery === 'clear') {
|
||||
setMessages([{
|
||||
role: 'system',
|
||||
content: '🌍✅ Chat cleared. Ready for queries.',
|
||||
timestamp: Date.now(),
|
||||
}]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// API queries
|
||||
try {
|
||||
const base = '/api/ai';
|
||||
|
||||
if (lowerQuery === 'status') {
|
||||
const resp = await fetch(`${base}/status`);
|
||||
const data = await resp.json();
|
||||
return {
|
||||
content: `🌍✅ SHADOWBROKER AI STATUS:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Status: ${data.status || 'ONLINE'}
|
||||
Capabilities: ${(data.capabilities || []).join(', ')}
|
||||
Pin Count: ${data.pin_count ?? 'N/A'}
|
||||
Version: ${data.version || '1.0'}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerQuery === 'summary' || lowerQuery === 'quick summary') {
|
||||
const resp = await fetch(`${base}/summary`);
|
||||
const data = await resp.json();
|
||||
const counts = data.layer_counts || {};
|
||||
const lines = Object.entries(counts)
|
||||
.filter(([, v]) => (v as number) > 0)
|
||||
.map(([k, v]) => ` • ${k}: ${v}`)
|
||||
.join('\n');
|
||||
return {
|
||||
content: `🌍📡 TELEMETRY SUMMARY:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
${lines || ' No active telemetry data.'}
|
||||
|
||||
Threat Level: ${data.threat_level || 'N/A'}
|
||||
SIGINT Totals: ${JSON.stringify(data.sigint_totals || {}, null, 0)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerQuery === 'report' || lowerQuery === 'intelligence report') {
|
||||
const resp = await fetch(`${base}/report`);
|
||||
const data = await resp.json();
|
||||
return {
|
||||
content: `🌍🛰️ INTELLIGENCE REPORT:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
${data.report || JSON.stringify(data, null, 2)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerQuery.startsWith('pin ')) {
|
||||
const parts = lowerQuery.replace('pin ', '').split(/\s+/);
|
||||
if (parts.length >= 3) {
|
||||
const lat = parseFloat(parts[0]);
|
||||
const lng = parseFloat(parts[1]);
|
||||
const label = parts.slice(2).join(' ');
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
const resp = await fetch(`${base}/pins`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ lat, lng, label, category: 'research' }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
return {
|
||||
content: `🌍📌 SHADOWBROKER PINNING:
|
||||
Pin placed successfully!
|
||||
📍 ${lat.toFixed(4)}°, ${lng.toFixed(4)}°
|
||||
🏷️ ${label}
|
||||
🆔 ${data.pin_id || 'assigned'}`,
|
||||
pins: [{ lat, lng, label }],
|
||||
};
|
||||
}
|
||||
}
|
||||
return { content: '❌ Usage: pin [latitude] [longitude] [label]' };
|
||||
}
|
||||
|
||||
if (lowerQuery === 'list pins' || lowerQuery === 'pins') {
|
||||
const resp = await fetch(`${base}/pins`);
|
||||
const data = await resp.json();
|
||||
const pinList = (data.pins || [])
|
||||
.slice(0, 20)
|
||||
.map((p: { label: string; lat: number; lng: number; category: string }) =>
|
||||
` 📍 ${p.label} (${p.lat.toFixed(2)}°, ${p.lng.toFixed(2)}°) [${p.category}]`
|
||||
)
|
||||
.join('\n');
|
||||
return {
|
||||
content: `🌍📌 AI INTEL PINS (${data.count || 0}):
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
${pinList || ' No pins placed yet.'}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerQuery === 'clear pins') {
|
||||
await fetch(`${base}/pins`, { method: 'DELETE' });
|
||||
return { content: '🌍❌ SHADOWBROKER CLEARING:\nAll AI intel pins cleared.' };
|
||||
}
|
||||
|
||||
if (lowerQuery === 'snapshot' || lowerQuery === 'take snapshot') {
|
||||
const resp = await fetch(`${base}/timemachine/snapshot`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
return {
|
||||
content: `🌍🕰️ SHADOWBROKER TIMEMACHINE:
|
||||
Snapshot taken!
|
||||
🆔 ${data.snapshot_id}
|
||||
🕐 ${data.timestamp}
|
||||
📊 Layers: ${(data.layers || []).join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerQuery === 'snapshots' || lowerQuery === 'list snapshots') {
|
||||
const resp = await fetch(`${base}/timemachine/snapshots`);
|
||||
const data = await resp.json();
|
||||
const snapList = (data.snapshots || [])
|
||||
.slice(0, 10)
|
||||
.map((s: { id: string; timestamp: string; layers: string[] }) =>
|
||||
` 🗂️ ${s.id} — ${s.timestamp} (${s.layers.length} layers)`
|
||||
)
|
||||
.join('\n');
|
||||
return {
|
||||
content: `🌍🕰️ TIME MACHINE SNAPSHOTS (${data.count || 0}):
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
${snapList || ' No snapshots taken yet.'}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerQuery === 'timemachine config' || lowerQuery === 'tm config') {
|
||||
const resp = await fetch(`${base}/timemachine/config`);
|
||||
const data = await resp.json();
|
||||
const cfg = data.config || {};
|
||||
return {
|
||||
content: `🌍🕰️ TIME MACHINE CONFIG:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Preset: ${cfg.preset || 'active'}
|
||||
|
||||
High-Frequency (${cfg.profiles?.high_freq?.interval_minutes || 15}min):
|
||||
${(cfg.profiles?.high_freq?.layers || []).join(', ')}
|
||||
|
||||
Standard (${cfg.profiles?.standard?.interval_minutes || 120}min):
|
||||
${(cfg.profiles?.standard?.layers || []).join(', ')}
|
||||
|
||||
Available presets: paranoid (5min), active (15min), casual (1hr), minimal (6hr)`,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerQuery === 'news summary' || lowerQuery === 'news brief' || lowerQuery === 'ai brief') {
|
||||
const resp = await fetch(`${base}/news/summary`);
|
||||
const data = await resp.json();
|
||||
const topStories = (data.top_stories || [])
|
||||
.slice(0, 5)
|
||||
.map((s: { risk_score: number; title: string; source: string }) =>
|
||||
` [${s.risk_score}/10] ${s.title} — ${s.source}`
|
||||
)
|
||||
.join('\n');
|
||||
const keywords = (data.keywords || [])
|
||||
.slice(0, 8)
|
||||
.map((kw: { word: string; count: number }) => `${kw.word}(${kw.count})`)
|
||||
.join(', ');
|
||||
const td = data.threat_distribution || {};
|
||||
return {
|
||||
content: `🌍📰 AI INTELLIGENCE BRIEF:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
${data.summary || 'No data available.'}
|
||||
|
||||
TOP STORIES:
|
||||
${topStories || ' None available.'}
|
||||
|
||||
TRENDING: ${keywords || 'N/A'}
|
||||
|
||||
THREAT DISTRIBUTION:
|
||||
🔴 CRITICAL: ${td.CRITICAL || 0} 🟠 HIGH: ${td.HIGH || 0} 🟡 ELEVATED: ${td.ELEVATED || 0}
|
||||
🔵 MODERATE: ${td.MODERATE || 0} 🟢 LOW: ${td.LOW || 0}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerQuery === 'correlations' || lowerQuery === 'explain correlations' || lowerQuery === 'correlation alerts') {
|
||||
const resp = await fetch(`${base}/correlations/explain`);
|
||||
const data = await resp.json();
|
||||
if (!data.count) {
|
||||
return { content: '🌍⚡ CORRELATIONS:\nNo cross-layer correlation alerts are currently active.' };
|
||||
}
|
||||
const alerts = (data.explanations || [])
|
||||
.slice(0, 8)
|
||||
.map((e: { label: string; severity_text: string; driver_summary: string; implications: string[]; recommended_action: string; lat: number; lng: number }) =>
|
||||
`━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📍 ${e.label}
|
||||
Location: ${e.lat.toFixed(2)}°, ${e.lng.toFixed(2)}°
|
||||
Severity: ${e.severity_text}
|
||||
Indicators: ${e.driver_summary}
|
||||
Assessment: ${e.implications?.[0] || 'N/A'}
|
||||
Action: ${e.recommended_action}`
|
||||
)
|
||||
.join('\n');
|
||||
return {
|
||||
content: `🌍⚡ CORRELATION ANALYSIS (${data.count} alerts):
|
||||
${data.summary || ''}
|
||||
|
||||
${alerts}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Generic fallback — try summary
|
||||
return {
|
||||
content: `🌍🔍 SHADOWBROKER SEARCHING:
|
||||
Processing query: "${query}"
|
||||
|
||||
I can directly execute these commands:
|
||||
• summary / report / status
|
||||
• pin [lat] [lng] [label]
|
||||
• list pins / clear pins
|
||||
• snapshot / snapshots / timemachine config
|
||||
• help
|
||||
|
||||
For complex queries (natural language research, web search,
|
||||
multi-step investigations), connect OpenClaw with an LLM
|
||||
provider to unlock full agent capabilities.
|
||||
|
||||
Type "help" for the full command list.`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: `🌍⚠️ SHADOWBROKER WARNING:
|
||||
Query failed: ${error instanceof Error ? error.message : 'Unknown error'}
|
||||
Make sure the ShadowBroker backend is running on localhost:8000.`,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const query = input.trim();
|
||||
if (!query || isLoading) return;
|
||||
|
||||
setInput('');
|
||||
setMessages(prev => [...prev, { role: 'user', content: query, timestamp: Date.now() }]);
|
||||
setIsLoading(true);
|
||||
|
||||
const result = await processQuery(query);
|
||||
if (result) {
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'ai',
|
||||
content: result.content,
|
||||
timestamp: Date.now(),
|
||||
pins: result.pins,
|
||||
}]);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [input, isLoading, processQuery]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-[#0a0a0a] text-gray-300">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-purple-900/40 bg-purple-950/10 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-gray-500 hover:text-gray-300 transition-colors"
|
||||
title="Back to terminal"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<Brain size={18} className="text-purple-400" />
|
||||
<span className="text-sm tracking-[0.2em] text-purple-400 uppercase font-bold">
|
||||
AI Co-Pilot
|
||||
</span>
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse shadow-[0_0_6px_rgba(34,197,94,0.6)]" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowConnect(!showConnect)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs font-bold tracking-wider uppercase transition-all rounded-sm ${
|
||||
showConnect
|
||||
? 'bg-purple-900/40 border border-purple-500/50 text-purple-300'
|
||||
: 'bg-purple-900/20 border border-purple-800/30 text-purple-500 hover:bg-purple-900/30 hover:text-purple-300 hover:border-purple-600/40'
|
||||
}`}
|
||||
title="Connect your OpenClaw agent"
|
||||
>
|
||||
<Link2 size={13} />
|
||||
Connect OpenClaw
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMessages([{
|
||||
role: 'system',
|
||||
content: '🌍✅ Chat cleared. Ready for queries.',
|
||||
timestamp: Date.now(),
|
||||
}])}
|
||||
className="text-gray-600 hover:text-red-400 transition-colors"
|
||||
title="Clear chat"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connect OpenClaw Panel */}
|
||||
{showConnect && (
|
||||
<div className="border-b border-purple-900/40 bg-purple-950/15 px-4 py-4 shrink-0 overflow-y-auto max-h-[60vh]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 size={14} className="text-purple-400" />
|
||||
<span className="text-sm font-bold tracking-wider text-purple-400 uppercase">Connect Your OpenClaw Agent</span>
|
||||
</div>
|
||||
<button onClick={() => setShowConnect(false)} className="text-gray-600 hover:text-gray-300 transition-colors">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-sm font-mono">
|
||||
{/* API Endpoint */}
|
||||
<div>
|
||||
<div className="text-[11px] text-gray-500 uppercase tracking-widest mb-1">Your ShadowBroker API Endpoint</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-black/60 border border-purple-800/40 px-3 py-2 text-purple-300 text-sm rounded-sm select-all">
|
||||
{apiEndpoint}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => handleCopy(apiEndpoint)}
|
||||
className="p-2 bg-purple-900/30 border border-purple-800/40 text-purple-400 hover:bg-purple-900/50 hover:text-purple-200 transition-colors rounded-sm"
|
||||
title="Copy endpoint"
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Setup Instructions */}
|
||||
<div>
|
||||
<div className="text-[11px] text-gray-500 uppercase tracking-widest mb-1">Setup Instructions</div>
|
||||
<div className="bg-black/60 border border-gray-800/40 rounded-sm p-3 space-y-2 text-[13px] leading-relaxed">
|
||||
<p className="text-cyan-400 font-bold">Step 1: Install the ShadowBroker Skill</p>
|
||||
<p className="text-gray-400">Copy the <code className="text-purple-300 bg-purple-900/30 px-1">openclaw-skills/shadowbroker/</code> folder into your OpenClaw's skills directory.</p>
|
||||
|
||||
<p className="text-cyan-400 font-bold mt-2">Step 2: Configure the API Endpoint</p>
|
||||
<p className="text-gray-400">Tell your OpenClaw agent to connect to:</p>
|
||||
<code className="block bg-purple-950/40 border border-purple-800/30 px-2 py-1 text-purple-300 text-[13px] rounded-sm">
|
||||
SHADOWBROKER_URL={apiEndpoint}
|
||||
</code>
|
||||
|
||||
<p className="text-cyan-400 font-bold mt-2">Step 3: Tell Your Agent</p>
|
||||
<p className="text-gray-400">Paste this into your OpenClaw's system prompt or instructions:</p>
|
||||
<div className="relative">
|
||||
<pre className="bg-purple-950/40 border border-purple-800/30 px-2 py-2 text-[12px] text-purple-200 rounded-sm overflow-x-auto whitespace-pre-wrap">{`You have a skill called "shadowbroker" that connects you to a real-time global OSINT intelligence platform. Use it to:
|
||||
- Query military flights, ships, satellites, SIGINT, earthquakes, and 30+ data layers
|
||||
- Place intelligence pins on a live map
|
||||
- Fetch satellite imagery from Sentinel-2
|
||||
- Aggregate news by region via GDELT
|
||||
- Take telemetry snapshots (Time Machine)
|
||||
- Participate in the Wormhole encrypted mesh network
|
||||
- Send/receive InfoNet messages via decentralized feed
|
||||
|
||||
API: ${apiEndpoint}
|
||||
Skill docs: openclaw-skills/shadowbroker/SKILL.md`}</pre>
|
||||
<button
|
||||
onClick={() => handleCopy(`You have a skill called "shadowbroker" that connects you to a real-time global OSINT intelligence platform. Use it to:\n- Query military flights, ships, satellites, SIGINT, earthquakes, and 30+ data layers\n- Place intelligence pins on a live map\n- Fetch satellite imagery from Sentinel-2\n- Aggregate news by region via GDELT\n- Take telemetry snapshots (Time Machine)\n- Participate in the Wormhole encrypted mesh network\n- Send/receive InfoNet messages via decentralized feed\n\nAPI: ${apiEndpoint}\nSkill docs: openclaw-skills/shadowbroker/SKILL.md`)}
|
||||
className="absolute top-1 right-1 p-1 bg-purple-900/50 text-purple-400 hover:text-purple-200 transition-colors rounded-sm"
|
||||
title="Copy instructions"
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Capabilities */}
|
||||
<div>
|
||||
<div className="text-[11px] text-gray-500 uppercase tracking-widest mb-1">Available Capabilities</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{[
|
||||
['📡', 'Telemetry Queries'],
|
||||
['📌', 'Pin Placement'],
|
||||
['🛰️', 'Satellite Imagery'],
|
||||
['📰', 'News Aggregation'],
|
||||
['🕰️', 'Time Machine'],
|
||||
['🔗', 'Wormhole Network'],
|
||||
['📻', 'Meshtastic Radio'],
|
||||
['💉', 'Data Injection'],
|
||||
['⚡', 'Correlation Analysis'],
|
||||
['🚨', 'Alert Dispatch'],
|
||||
].map(([emoji, label]) => (
|
||||
<div key={label} className="flex items-center gap-1.5 text-[12px] text-gray-400 bg-black/30 border border-gray-800/30 px-2 py-1 rounded-sm">
|
||||
<span>{emoji}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] px-3 py-2.5 text-[13px] leading-relaxed whitespace-pre-wrap ${
|
||||
msg.role === 'user'
|
||||
? 'bg-purple-900/30 border border-purple-700/40 text-purple-100'
|
||||
: msg.role === 'system'
|
||||
? 'bg-cyan-950/20 border border-cyan-900/30 text-cyan-300'
|
||||
: 'bg-gray-900/40 border border-gray-800/40 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
{msg.pins && msg.pins.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-700/30 flex items-center gap-1.5 text-green-400 text-sm">
|
||||
<MapPin size={12} />
|
||||
<span>{msg.pins.length} pin(s) placed on map</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-gray-900/40 border border-gray-800/40 px-3 py-2.5 flex items-center gap-2 text-sm text-gray-500">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<span>Processing query...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Quick suggestions */}
|
||||
{messages.length <= 2 && (
|
||||
<div className="px-4 pb-2 flex flex-wrap gap-1.5">
|
||||
{EXAMPLE_QUERIES.slice(0, 4).map((q, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => { setInput(q); inputRef.current?.focus(); }}
|
||||
className="text-xs px-2.5 py-1 bg-purple-900/15 border border-purple-800/30 text-purple-400 hover:bg-purple-900/30 hover:text-purple-300 transition-colors flex items-center gap-1.5 rounded-sm"
|
||||
>
|
||||
<Sparkles size={10} />
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div className="shrink-0 px-4 py-3 border-t border-purple-900/30 bg-purple-950/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 flex items-center bg-gray-900/40 border border-gray-700/40 focus-within:border-purple-700/60 transition-colors rounded-sm">
|
||||
<span className="text-purple-500 text-sm px-2.5 select-none">❯</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask anything... (type 'help' for commands)"
|
||||
className="flex-1 bg-transparent border-none outline-none text-white text-sm py-2.5 pr-2 placeholder-gray-600 focus:ring-0"
|
||||
disabled={isLoading}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || !input.trim()}
|
||||
className="p-2 bg-purple-900/30 border border-purple-700/40 text-purple-400 hover:bg-purple-900/50 hover:text-purple-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ChevronLeft, Cpu, Loader, AlertCircle, CheckCircle2, XCircle, Server } from 'lucide-react';
|
||||
import {
|
||||
buildBootstrapResolutionVotePayload,
|
||||
fetchBootstrapMarketState,
|
||||
fetchInfonetStatus,
|
||||
type BootstrapMarketState,
|
||||
type InfonetStatus,
|
||||
} from '@/mesh/infonetEconomyClient';
|
||||
import { generateNodeKeys, getNodeIdentity } from '@/mesh/meshIdentity';
|
||||
import {
|
||||
DEFAULT_INFONET_SEED_URL,
|
||||
fetchInfonetNodeStatusSnapshot,
|
||||
setInfonetNodeEnabled,
|
||||
type InfonetNodeStatusSnapshot,
|
||||
} from '@/mesh/controlPlaneStatusClient';
|
||||
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
|
||||
|
||||
interface BootstrapViewProps {
|
||||
marketId?: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function BootstrapView({ marketId, onBack }: BootstrapViewProps) {
|
||||
const [status, setStatus] = useState<InfonetStatus | null>(null);
|
||||
const [market, setMarket] = useState<BootstrapMarketState | null>(null);
|
||||
const [nodeStatus, setNodeStatus] = useState<InfonetNodeStatusSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [nodeToggleBusy, setNodeToggleBusy] = useState(false);
|
||||
const [nodeToggleError, setNodeToggleError] = useState<string | null>(null);
|
||||
const [voteSide, setVoteSide] = useState<'yes' | 'no'>('yes');
|
||||
const [powNonce, setPowNonce] = useState('0');
|
||||
const voteAction = useSignAndAppend();
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [s, m, n] = await Promise.all([
|
||||
fetchInfonetStatus(),
|
||||
marketId ? fetchBootstrapMarketState(marketId).catch(() => null) : Promise.resolve(null),
|
||||
fetchInfonetNodeStatusSnapshot(true).catch(() => null),
|
||||
]);
|
||||
setStatus(s);
|
||||
setMarket(m);
|
||||
setNodeStatus(n);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'network error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [marketId]);
|
||||
|
||||
const nodeEnabled = Boolean(nodeStatus?.node_enabled);
|
||||
const nodeMode = String(nodeStatus?.node_mode || 'participant').toUpperCase();
|
||||
const syncOutcome = String(nodeStatus?.sync_runtime?.last_outcome || 'idle').toLowerCase();
|
||||
const seedPeerCount = Number(nodeStatus?.bootstrap?.default_sync_peer_count || 0);
|
||||
const syncPeerCount = Number(nodeStatus?.bootstrap?.sync_peer_count || 0);
|
||||
const lastPeerUrl = String(nodeStatus?.sync_runtime?.last_peer_url || '').trim();
|
||||
|
||||
const toggleNode = useCallback(async (enabled: boolean) => {
|
||||
setNodeToggleBusy(true);
|
||||
setNodeToggleError(null);
|
||||
try {
|
||||
if (enabled && !getNodeIdentity()) {
|
||||
await generateNodeKeys();
|
||||
}
|
||||
await setInfonetNodeEnabled(enabled);
|
||||
const next = await fetchInfonetNodeStatusSnapshot(true);
|
||||
setNodeStatus(next);
|
||||
} catch (err) {
|
||||
setNodeToggleError(err instanceof Error ? err.message : 'node settings update failed');
|
||||
} finally {
|
||||
setNodeToggleBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hasActivePhase = !!market && market.tally.total_eligible >= 0
|
||||
&& market.tally.yes + market.tally.no < market.tally.total_eligible;
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [reload, hasActivePhase]);
|
||||
|
||||
const submitVote = useCallback(async () => {
|
||||
if (!marketId) return;
|
||||
const nonce = Number(powNonce);
|
||||
if (!Number.isFinite(nonce) || nonce < 0) return;
|
||||
const built = buildBootstrapResolutionVotePayload(marketId, voteSide, Math.floor(nonce));
|
||||
const res = await voteAction.submit(built.event_type, built.payload);
|
||||
if (res.ok) {
|
||||
void reload();
|
||||
}
|
||||
}, [marketId, voteSide, powNonce, voteAction, reload]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
|
||||
<button onClick={onBack} className="flex items-center text-cyan-400 hover:text-cyan-300 text-sm">
|
||||
<ChevronLeft size={14} className="mr-1" /> BACK
|
||||
</button>
|
||||
<div className="text-sm text-cyan-400 font-bold uppercase tracking-widest flex items-center gap-2">
|
||||
<Cpu size={16} /> BOOTSTRAP MODE
|
||||
</div>
|
||||
<button onClick={() => void reload()} disabled={loading} className="text-xs text-gray-500 hover:text-cyan-400 disabled:opacity-30">
|
||||
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
|
||||
<div className="text-xs text-gray-500 leading-relaxed">
|
||||
The first <span className="text-cyan-400">bootstrap_market_count</span> (default 100) markets
|
||||
resolve via <span className="text-cyan-400">eligible-node-one-vote</span> instead of oracle-rep-weighted
|
||||
staking. Eligibility: identity age ≥ 3 days vs market.snapshot.frozen_at,
|
||||
NOT in the predictor exclusion set, and a valid Argon2id PoW
|
||||
(Heavy-Node-only — requires ≥64MB RAM per computation).
|
||||
Once node count crosses <span className="text-cyan-400">bootstrap_threshold</span> (default 1000),
|
||||
new markets default to staked resolution. Existing bootstrap-indexed markets continue under
|
||||
bootstrap rules until they resolve.
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
|
||||
<AlertCircle size={12} className="inline mr-1" />{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-cyan-900/50 bg-cyan-950/10 p-3">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<div className="text-xs uppercase tracking-wider text-cyan-400 flex items-center gap-2">
|
||||
<Server size={14} /> Network Seed
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void reload()}
|
||||
disabled={loading}
|
||||
className="text-[10px] text-gray-500 hover:text-cyan-400 disabled:opacity-30 uppercase tracking-widest"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<div className="text-gray-500">Default Seed</div>
|
||||
<div className="text-cyan-300 font-mono break-all">{DEFAULT_INFONET_SEED_URL}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Local Node</div>
|
||||
<div className={nodeEnabled ? 'text-green-400' : 'text-gray-500'}>
|
||||
{nodeEnabled ? `${nodeMode} ONLINE` : `${nodeMode} OFF`}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Sync Path</div>
|
||||
<div className="text-white font-mono">
|
||||
{syncPeerCount} peers / {seedPeerCount} default
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col md:flex-row md:items-center gap-3">
|
||||
<div className="flex-1 text-[11px] text-gray-500 leading-relaxed">
|
||||
{nodeEnabled
|
||||
? `Public chain sync is ${syncOutcome || 'active'}${lastPeerUrl ? ` via ${lastPeerUrl}` : ''}.`
|
||||
: 'Start a local participant node to pull from the default seed and help carry the public Infonet chain while this backend is running.'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleNode(!nodeEnabled)}
|
||||
disabled={nodeToggleBusy}
|
||||
className={
|
||||
nodeEnabled
|
||||
? 'px-3 py-2 border border-rose-700/50 bg-rose-950/20 text-rose-300 hover:bg-rose-950/35 disabled:opacity-40 text-[10px] uppercase tracking-wider'
|
||||
: 'px-3 py-2 border border-cyan-700/50 bg-cyan-900/20 text-cyan-300 hover:bg-cyan-900/40 disabled:opacity-40 text-[10px] uppercase tracking-wider'
|
||||
}
|
||||
>
|
||||
{nodeToggleBusy ? 'Updating...' : nodeEnabled ? 'Turn Off Node' : 'Start Node'}
|
||||
</button>
|
||||
</div>
|
||||
{nodeToggleError && (
|
||||
<div className="mt-3 border border-amber-900/50 bg-amber-950/20 p-2 text-[11px] text-amber-300">
|
||||
<AlertCircle size={11} className="inline mr-1" />{nodeToggleError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className="border border-gray-800 bg-black/40 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-cyan-400 mb-2">Network Ramp</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<div className="text-gray-500">Distinct Nodes</div>
|
||||
<div className="text-white font-mono text-lg">{status.ramp.node_count}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Bootstrap Resolution</div>
|
||||
<div className={status.ramp.bootstrap_resolution_active ? 'text-green-400' : 'text-gray-500'}>
|
||||
{status.ramp.bootstrap_resolution_active ? 'ACTIVE' : 'TRANSITIONED'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Staked Resolution</div>
|
||||
<div className={status.ramp.staked_resolution_active ? 'text-green-400' : 'text-gray-500'}>
|
||||
{status.ramp.staked_resolution_active ? 'ACTIVE' : 'LOCKED'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Petitions</div>
|
||||
<div className={status.ramp.governance_petitions_active ? 'text-green-400' : 'text-gray-500'}>
|
||||
{status.ramp.governance_petitions_active ? 'ACTIVE' : 'LOCKED'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Upgrade Governance</div>
|
||||
<div className={status.ramp.upgrade_governance_active ? 'text-green-400' : 'text-gray-500'}>
|
||||
{status.ramp.upgrade_governance_active ? 'ACTIVE' : 'LOCKED'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">CommonCoin</div>
|
||||
<div className={status.ramp.commoncoin_active ? 'text-green-400' : 'text-gray-500'}>
|
||||
{status.ramp.commoncoin_active ? 'ACTIVE' : 'LOCKED'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{market && (
|
||||
<div className="border border-gray-800 bg-black/40 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-cyan-400 mb-2">
|
||||
Market: <span className="font-mono text-white">{market.market_id}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs mb-3">
|
||||
<div>
|
||||
<div className="text-gray-500">YES votes</div>
|
||||
<div className="text-green-400 font-mono text-lg">{market.tally.yes}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">NO votes</div>
|
||||
<div className="text-red-400 font-mono text-lg">{market.tally.no}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Total Eligible</div>
|
||||
<div className="text-white font-mono text-lg">{market.tally.total_eligible}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Min Required</div>
|
||||
<div className="text-gray-300 font-mono text-lg">{market.tally.min_market_participants}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-cyan-900/50 bg-cyan-900/10 p-2 mb-3 text-xs">
|
||||
<div className="text-cyan-400 font-bold uppercase tracking-wider mb-2">
|
||||
Cast Bootstrap Vote
|
||||
</div>
|
||||
<div className="text-gray-500 mb-2">
|
||||
Eligibility: identity age ≥{' '}
|
||||
{status ? '3 days' : 'configured threshold'}{' '}
|
||||
vs market.snapshot.frozen_at, NOT in predictor exclusion set,
|
||||
and a valid Argon2id PoW (Heavy-Node-only). The PoW nonce
|
||||
input is for testnet — production wires the Argon2id solver
|
||||
via privacy-core when the Rust binding lands.
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={voteSide}
|
||||
onChange={(e) => setVoteSide(e.target.value as 'yes' | 'no')}
|
||||
title="Bootstrap vote side"
|
||||
aria-label="Bootstrap vote side"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
>
|
||||
<option value="yes">YES</option>
|
||||
<option value="no">NO</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={powNonce}
|
||||
onChange={(e) => setPowNonce(e.target.value)}
|
||||
placeholder="pow_nonce"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-32"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitVote}
|
||||
disabled={voteAction.state === 'submitting' || !marketId}
|
||||
className="px-3 py-1 uppercase tracking-wider border border-cyan-700/50 bg-cyan-900/20 text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-30"
|
||||
>
|
||||
{voteAction.state === 'submitting' ? 'Submitting…' : 'Cast Vote'}
|
||||
</button>
|
||||
</div>
|
||||
{voteAction.result && !voteAction.result.ok && (
|
||||
<div className="text-red-400 font-mono mt-2 break-all">
|
||||
<AlertCircle size={10} className="inline mr-1" />
|
||||
{voteAction.result.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs uppercase tracking-wider text-gray-500 mb-2">All Submitted Votes</div>
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{market.votes.map((v) => (
|
||||
<div key={v.node_id} className="flex items-center justify-between gap-2 text-xs border-b border-gray-800/30 py-1">
|
||||
<span className="font-mono text-gray-400 truncate flex-1">{v.node_id.slice(0, 16)}…</span>
|
||||
<span className={v.side === 'yes' ? 'text-green-400' : 'text-red-400'}>{v.side?.toUpperCase()}</span>
|
||||
<span className="w-20 text-right">
|
||||
{v.eligible ? (
|
||||
<CheckCircle2 size={12} className="text-green-400 inline" />
|
||||
) : (
|
||||
<span className="text-amber-400 flex items-center justify-end gap-1">
|
||||
<XCircle size={12} />
|
||||
<span className="text-xs">{v.ineligible_reason}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!marketId && (
|
||||
<div className="border border-gray-800 bg-black/40 p-6 text-center text-xs text-gray-500">
|
||||
Open a bootstrap-indexed market from the Markets view to see its
|
||||
eligible-node-one-vote tally here.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ChevronLeft, KeyRound, ShieldCheck, AlertTriangle, FileKey } from 'lucide-react';
|
||||
import { fetchInfonetStatus, type InfonetStatus } from '@/mesh/infonetEconomyClient';
|
||||
|
||||
interface FunctionKeyViewProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const PIECE_STATUS: Record<string, { color: string; label: string }> = {
|
||||
not_implemented: { color: 'text-gray-500', label: 'NOT IMPLEMENTED' },
|
||||
scaffolding: { color: 'text-amber-400', label: 'SCAFFOLDING' },
|
||||
reference_impl: { color: 'text-blue-400', label: 'REFERENCE' },
|
||||
production_rust: { color: 'text-green-400', label: 'PRODUCTION' },
|
||||
};
|
||||
|
||||
export default function FunctionKeyView({ onBack }: FunctionKeyViewProps) {
|
||||
const [status, setStatus] = useState<InfonetStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const s = await fetchInfonetStatus();
|
||||
if (!cancelled) setStatus(s);
|
||||
} catch {
|
||||
// ignore — render the design overview without status
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
|
||||
<button onClick={onBack} className="flex items-center text-cyan-400 hover:text-cyan-300 text-sm">
|
||||
<ChevronLeft size={14} className="mr-1" /> BACK
|
||||
</button>
|
||||
<div className="text-sm text-purple-400 font-bold uppercase tracking-widest flex items-center gap-2">
|
||||
<KeyRound size={16} /> FUNCTION KEYS — Anonymous Citizenship Proof
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
|
||||
<div className="text-xs text-gray-400 leading-relaxed">
|
||||
A citizen proves "I am an Infonet citizen" to a real-world
|
||||
operator <span className="text-purple-400">without revealing their Infonet identity</span>.
|
||||
The naive approach (scramble a public key, record each redemption on chain) leaks
|
||||
identity through metadata correlation. The Function Keys design is six pieces;
|
||||
five are implemented; one (issuance via blind signatures / anonymous credentials)
|
||||
waits on a cryptographic primitive decision.
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className="border border-gray-800 bg-black/40 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-purple-400 mb-2 flex items-center gap-1">
|
||||
<ShieldCheck size={12} /> Privacy Primitive Status
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||
{Object.entries(status.privacy_primitive_status).map(([k, v]) => {
|
||||
const style = PIECE_STATUS[v] ?? PIECE_STATUS.not_implemented;
|
||||
return (
|
||||
<div key={k}>
|
||||
<div className="text-gray-500 capitalize">{k.replace(/_/g, ' ')}</div>
|
||||
<div className={`${style.color} font-bold`}>{style.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
Cryptographic primitives are stubbed via the locked Protocol contracts in
|
||||
<span className="font-mono"> services/infonet/privacy/contracts.py</span>.
|
||||
When the privacy-core Rust binding lands, the scaffolding swaps for the
|
||||
production class — no caller changes.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-gray-800 bg-black/40 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-purple-400 mb-2">
|
||||
The Six Pieces
|
||||
</div>
|
||||
<ol className="text-xs space-y-2 text-gray-300">
|
||||
<li>
|
||||
<span className="text-amber-400 font-bold">1. Issuance</span>{' '}
|
||||
<span className="text-gray-500">(NOT IMPLEMENTED — blind sig / BBS+ / U-Prove / Idemix)</span>
|
||||
<div className="text-gray-400 ml-4">
|
||||
Protocol issues a credential proving citizenship without linking to node_id.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-green-400 font-bold">2. Nullifiers</span>{' '}
|
||||
<span className="text-gray-500">(implemented — pure SHA-256)</span>
|
||||
<div className="text-gray-400 ml-4">
|
||||
<span className="font-mono">nullifier = H(secret || operator_id)</span>.
|
||||
Different operators see different nullifiers for the same key — no
|
||||
cross-operator linkage. One-time-use per (key, operator) pair via a
|
||||
tracker.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-green-400 font-bold">3. Challenge-Response</span>{' '}
|
||||
<span className="text-gray-500">(implemented — HMAC-SHA256 placeholder)</span>
|
||||
<div className="text-gray-400 ml-4">
|
||||
Operator issues a fresh nonce; key-holder signs with the Function Key's
|
||||
secret. Defeats screenshot, replay, key-sharing. Production wires the chosen
|
||||
blind-sig scheme; API stays compatible.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-green-400 font-bold">4. Two-Phase Commit Receipts</span>{' '}
|
||||
<span className="text-gray-500">(implemented)</span>
|
||||
<div className="text-gray-400 ml-4">
|
||||
Phase 1: operator signs a verification receipt (day-bucket date,
|
||||
nullifier prefix only — NO timestamps, NO full nullifiers, NO node_id).
|
||||
Phase 2: citizen counter-signs after service rendered. Both parties hold
|
||||
a copy. <span className="text-purple-400">Receipts NEVER published on-chain.</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-green-400 font-bold">5. Enumerated Denial Codes</span>{' '}
|
||||
<span className="text-gray-500">(implemented — 3-value enum)</span>
|
||||
<div className="text-gray-400 ml-4">
|
||||
Operators can reject for exactly three reasons: invalid signature,
|
||||
nullifier already seen, rate limit exceeded. Adding a 4th code is a hard
|
||||
fork. Anti-discrimination by design.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-green-400 font-bold">6. Batched Settlement</span>{' '}
|
||||
<span className="text-gray-500">(implemented)</span>
|
||||
<div className="text-gray-400 ml-4">
|
||||
Operators settle in aggregate. Chain sees{' '}
|
||||
<span className="font-mono">{operator_id, period_id, count}</span>{' '}
|
||||
— never per-receipt detail. Fraud detection via statistical auditing,
|
||||
not per-redemption traces.
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="border border-amber-900/50 bg-amber-900/10 p-3">
|
||||
<div className="flex items-center gap-2 text-amber-400 text-xs font-bold uppercase tracking-wider mb-1">
|
||||
<AlertTriangle size={12} /> Production Readiness
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 space-y-1">
|
||||
<div>
|
||||
<FileKey size={11} className="inline mr-1" />
|
||||
The HMAC-SHA256 placeholder requires the verifier to know the citizen's
|
||||
secret — that is NOT private. Production replaces it with a blind-sig
|
||||
scheme that verifies without learning the secret.
|
||||
</div>
|
||||
<div>
|
||||
The cryptographic scheme decision (RSA blind sigs vs BBS+ vs U-Prove vs
|
||||
Idemix) is open per IMPLEMENTATION_PLAN §6.4.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ChevronLeft, AlertTriangle, Lock, Clock, ShieldOff, Loader, CheckCircle2 } from 'lucide-react';
|
||||
import {
|
||||
buildGateShutdownAppealFilePayload,
|
||||
buildGateShutdownFilePayload,
|
||||
buildGateSuspendFilePayload,
|
||||
fetchGateState,
|
||||
freshLocalId,
|
||||
type GateState,
|
||||
} from '@/mesh/infonetEconomyClient';
|
||||
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
|
||||
|
||||
interface GateShutdownViewProps {
|
||||
gateId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<string, { color: string; label: string; icon: typeof Lock }> = {
|
||||
active: { color: 'text-green-400', label: 'ACTIVE', icon: CheckCircle2 },
|
||||
suspended: { color: 'text-amber-400', label: 'SUSPENDED', icon: Clock },
|
||||
shutdown: { color: 'text-red-500', label: 'SHUTDOWN', icon: ShieldOff },
|
||||
};
|
||||
|
||||
function formatTs(ts: number | null): string {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function formatRelative(ts: number | null, now: number): string {
|
||||
if (!ts) return '—';
|
||||
const delta = ts - now;
|
||||
const abs = Math.abs(delta);
|
||||
const days = Math.floor(abs / 86400);
|
||||
const hours = Math.floor((abs % 86400) / 3600);
|
||||
if (delta > 0) {
|
||||
if (days > 0) return `in ${days}d ${hours}h`;
|
||||
return `in ${hours}h`;
|
||||
}
|
||||
if (days > 0) return `${days}d ago`;
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
export default function GateShutdownView({ gateId, onBack }: GateShutdownViewProps) {
|
||||
const [data, setData] = useState<GateState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filing forms — reused for suspend / shutdown / appeal.
|
||||
const [reason, setReason] = useState('');
|
||||
const [evidenceHash, setEvidenceHash] = useState('');
|
||||
const suspendAction = useSignAndAppend();
|
||||
const shutdownAction = useSignAndAppend();
|
||||
const appealAction = useSignAndAppend();
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetchGateState(gateId);
|
||||
if (res.ok) {
|
||||
setData(res);
|
||||
} else {
|
||||
setError(res.reason);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'network error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [gateId]);
|
||||
|
||||
const hasActivePhase = data?.suspension.status === 'suspended';
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [reload, hasActivePhase]);
|
||||
|
||||
const status = data ? STATUS_STYLE[data.suspension.status] ?? STATUS_STYLE.active : null;
|
||||
|
||||
const fileSuspend = useCallback(async () => {
|
||||
if (!reason.trim() || !evidenceHash.trim()) return;
|
||||
const built = buildGateSuspendFilePayload(
|
||||
freshLocalId('sus'), gateId, reason.trim(), [evidenceHash.trim()],
|
||||
);
|
||||
const res = await suspendAction.submit(built.event_type, built.payload);
|
||||
if (res.ok) {
|
||||
setReason(''); setEvidenceHash('');
|
||||
void reload();
|
||||
}
|
||||
}, [reason, evidenceHash, gateId, suspendAction, reload]);
|
||||
|
||||
const fileShutdown = useCallback(async () => {
|
||||
if (!reason.trim() || !evidenceHash.trim()) return;
|
||||
const built = buildGateShutdownFilePayload(
|
||||
freshLocalId('shd'), gateId, reason.trim(), [evidenceHash.trim()],
|
||||
);
|
||||
const res = await shutdownAction.submit(built.event_type, built.payload);
|
||||
if (res.ok) {
|
||||
setReason(''); setEvidenceHash('');
|
||||
void reload();
|
||||
}
|
||||
}, [reason, evidenceHash, gateId, shutdownAction, reload]);
|
||||
|
||||
const fileAppeal = useCallback(async () => {
|
||||
if (!reason.trim() || !evidenceHash.trim()) return;
|
||||
if (!data?.shutdown.pending_petition_id) return;
|
||||
const built = buildGateShutdownAppealFilePayload(
|
||||
freshLocalId('app'),
|
||||
gateId,
|
||||
data.shutdown.pending_petition_id,
|
||||
reason.trim(),
|
||||
[evidenceHash.trim()],
|
||||
);
|
||||
const res = await appealAction.submit(built.event_type, built.payload);
|
||||
if (res.ok) {
|
||||
setReason(''); setEvidenceHash('');
|
||||
void reload();
|
||||
}
|
||||
}, [reason, evidenceHash, gateId, data, appealAction, reload]);
|
||||
|
||||
const canFileSuspend = data?.suspension.status === 'active';
|
||||
const canFileShutdown = data?.suspension.status === 'suspended' && !data?.shutdown.has_pending;
|
||||
const canFileAppeal = data?.shutdown.pending_status === 'executing';
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
|
||||
<button onClick={onBack} className="flex items-center text-cyan-400 hover:text-cyan-300 text-sm">
|
||||
<ChevronLeft size={14} className="mr-1" /> BACK
|
||||
</button>
|
||||
<div className="text-sm text-amber-400 font-bold uppercase tracking-widest flex items-center gap-2">
|
||||
<ShieldOff size={16} /> GATE SHUTDOWN — {gateId}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void reload()}
|
||||
disabled={loading}
|
||||
className="text-xs text-gray-500 hover:text-amber-400 disabled:opacity-30"
|
||||
>
|
||||
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
|
||||
<div className="text-xs text-gray-500 leading-relaxed">
|
||||
Gate shutdown is two-tier: <span className="text-amber-400">SUSPEND</span> (30-day reversible freeze)
|
||||
→ <span className="text-red-400">SHUTDOWN</span> (irreversible archive, 7-day execution delay
|
||||
with one typed appeal allowed). Voting uses oracle_rep_active weight; thresholds are higher for
|
||||
locked gates (<span className="text-cyan-400">75% suspend / 80% shutdown</span> instead of 67% / 75%).
|
||||
Anti-stall: one appeal per shutdown, 48h filing window after vote passes.
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
|
||||
<AlertTriangle size={12} className="inline mr-1" /> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && status && (
|
||||
<>
|
||||
<div className="border border-gray-800 bg-black/40 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<status.icon size={14} className={status.color} />
|
||||
<span className={`text-xs font-bold uppercase tracking-wider ${status.color}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
{data.locked.is_locked && (
|
||||
<span className="ml-2 text-cyan-400 text-xs flex items-center gap-1">
|
||||
<Lock size={12} /> LOCKED
|
||||
</span>
|
||||
)}
|
||||
{data.ratified && (
|
||||
<span className="ml-2 text-green-400 text-xs">✓ RATIFIED</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<div className="text-gray-500">Members</div>
|
||||
<div className="text-white">{data.members.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Cumulative Oracle Rep</div>
|
||||
<div className="text-white">{data.cumulative_member_oracle_rep.toFixed(2)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Entry Sacrifice</div>
|
||||
<div className="text-white">{data.meta.entry_sacrifice} common rep</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Min Overall Rep</div>
|
||||
<div className="text-white">{data.meta.min_overall_rep}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Created</div>
|
||||
<div className="text-white text-xs">{formatTs(data.meta.created_at)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Locked At</div>
|
||||
<div className="text-white text-xs">
|
||||
{data.locked.locked_at ? formatTs(data.locked.locked_at) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.suspension.status === 'suspended' && (
|
||||
<div className="border border-amber-900/50 bg-amber-900/10 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-amber-400 mb-2 flex items-center gap-1">
|
||||
<Clock size={12} /> Suspension State
|
||||
</div>
|
||||
<div className="text-xs text-gray-300 space-y-1">
|
||||
<div>Suspended at: <span className="text-white">{formatTs(data.suspension.suspended_at)}</span></div>
|
||||
<div>
|
||||
Auto-unsuspends:{' '}
|
||||
<span className="text-amber-400">
|
||||
{formatRelative(data.suspension.suspended_until, data.now)} ({formatTs(data.suspension.suspended_until)})
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-gray-500 mt-2">
|
||||
During suspension: no gate_message, gate_enter, gate_exit. Members retain
|
||||
membership; content preserved (append-only).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.shutdown.has_pending && (
|
||||
<div className="border border-red-900/50 bg-red-900/10 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-red-400 mb-2 flex items-center gap-1">
|
||||
<ShieldOff size={12} /> Pending Shutdown Petition
|
||||
</div>
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="text-gray-300">
|
||||
ID: <span className="font-mono text-white">{data.shutdown.pending_petition_id}</span>
|
||||
</div>
|
||||
<div className="text-gray-300">
|
||||
Status:{' '}
|
||||
<span className={
|
||||
data.shutdown.pending_status === 'executing' ? 'text-red-400' :
|
||||
data.shutdown.pending_status === 'appealed' ? 'text-amber-400' :
|
||||
data.shutdown.pending_status === 'voided_appeal' ? 'text-green-400' :
|
||||
'text-gray-300'
|
||||
}>
|
||||
{data.shutdown.pending_status?.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{data.shutdown.execution_at && (
|
||||
<div className="text-red-400">
|
||||
Executes {formatRelative(data.shutdown.execution_at, data.now)} (
|
||||
{formatTs(data.shutdown.execution_at)})
|
||||
</div>
|
||||
)}
|
||||
{data.shutdown.pending_status === 'appealed' && (
|
||||
<div className="text-amber-400">
|
||||
⚠ Execution timer is PAUSED while appeal is voted on. If the appeal
|
||||
passes, the shutdown is voided. If it fails, the timer resumes.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.shutdown.executed && (
|
||||
<div className="border border-red-900/50 bg-red-900/20 p-3 text-xs">
|
||||
<div className="text-red-400 font-bold uppercase tracking-wider mb-1">
|
||||
GATE SHUT DOWN — IRREVERSIBLE
|
||||
</div>
|
||||
<div className="text-gray-400">
|
||||
Members released. Content archived. gate_id retired. No petition can reopen.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!data.shutdown.executed && (canFileSuspend || canFileShutdown || canFileAppeal) && (
|
||||
<div className="border border-gray-800 bg-black/40 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-gray-300 mb-2">
|
||||
File a Petition
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-2">
|
||||
Reason and at least one evidence hash are required. Filing
|
||||
costs common rep (suspend: 15, shutdown: 25, appeal: 20)
|
||||
and triggers a 7-day vote window.
|
||||
{canFileSuspend && ' Suspend → 30-day reversible freeze.'}
|
||||
{canFileShutdown && ' Shutdown requires active suspension.'}
|
||||
{canFileAppeal && ' Appeal pauses the 7-day execution timer.'}
|
||||
</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="reason (max 2000 chars)"
|
||||
maxLength={2000}
|
||||
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={evidenceHash}
|
||||
onChange={(e) => setEvidenceHash(e.target.value)}
|
||||
placeholder="evidence hash (e.g. ipfs://… or sha256:…)"
|
||||
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canFileSuspend && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={fileSuspend}
|
||||
disabled={
|
||||
suspendAction.state === 'submitting' ||
|
||||
!reason.trim() || !evidenceHash.trim()
|
||||
}
|
||||
className="px-3 py-1 uppercase tracking-wider border border-amber-700/50 bg-amber-900/20 text-amber-400 hover:bg-amber-900/40 disabled:opacity-30"
|
||||
>
|
||||
{suspendAction.state === 'submitting' ? 'Filing…' : 'File Suspend'}
|
||||
</button>
|
||||
)}
|
||||
{canFileShutdown && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={fileShutdown}
|
||||
disabled={
|
||||
shutdownAction.state === 'submitting' ||
|
||||
!reason.trim() || !evidenceHash.trim()
|
||||
}
|
||||
className="px-3 py-1 uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
|
||||
>
|
||||
{shutdownAction.state === 'submitting' ? 'Filing…' : 'File Shutdown'}
|
||||
</button>
|
||||
)}
|
||||
{canFileAppeal && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={fileAppeal}
|
||||
disabled={
|
||||
appealAction.state === 'submitting' ||
|
||||
!reason.trim() || !evidenceHash.trim()
|
||||
}
|
||||
className="px-3 py-1 uppercase tracking-wider border border-cyan-700/50 bg-cyan-900/20 text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-30"
|
||||
>
|
||||
{appealAction.state === 'submitting' ? 'Filing…' : 'File Appeal'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(suspendAction.result && !suspendAction.result.ok) && (
|
||||
<div className="text-red-400 font-mono text-xs mt-2 break-all">
|
||||
<AlertTriangle size={10} className="inline mr-1" />
|
||||
{suspendAction.result.reason}
|
||||
</div>
|
||||
)}
|
||||
{(shutdownAction.result && !shutdownAction.result.ok) && (
|
||||
<div className="text-red-400 font-mono text-xs mt-2 break-all">
|
||||
<AlertTriangle size={10} className="inline mr-1" />
|
||||
{shutdownAction.result.reason}
|
||||
</div>
|
||||
)}
|
||||
{(appealAction.result && !appealAction.result.ok) && (
|
||||
<div className="text-red-400 font-mono text-xs mt-2 break-all">
|
||||
<AlertTriangle size={10} className="inline mr-1" />
|
||||
{appealAction.result.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,17 +3,36 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, RefreshCw, Reply, Search, Send } from 'lucide-react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { controlPlaneJson } from '@/lib/controlPlane';
|
||||
import {
|
||||
nextGateMessagesPollDelayMs,
|
||||
nextGateMessagesWaitRearmDelayMs,
|
||||
nextGateMessagesWaitTimeoutMs,
|
||||
} from '@/mesh/gateMetadataTiming';
|
||||
import {
|
||||
ACTIVE_GATE_ROOM_MESSAGE_LIMIT,
|
||||
fetchGateMessageSnapshotState,
|
||||
waitForGateMessageSnapshot,
|
||||
} from '@/mesh/gateMessageSnapshot';
|
||||
import {
|
||||
getGateSessionStreamStatus,
|
||||
retainGateSessionStreamGate,
|
||||
subscribeGateSessionStreamEvents,
|
||||
subscribeGateSessionStreamStatus,
|
||||
} from '@/mesh/gateSessionStream';
|
||||
import { nextSequence } from '@/mesh/meshIdentity';
|
||||
import {
|
||||
approveGateCompatFallback,
|
||||
decryptWormholeGateMessages,
|
||||
fetchWormholeGateKeyStatus,
|
||||
hasGateCompatFallbackApproval,
|
||||
postWormholeGateMessage,
|
||||
prepareWormholeInteractiveLane,
|
||||
signMeshEvent,
|
||||
syncBrowserWormholeGateState,
|
||||
type WormholeGateKeyStatus,
|
||||
} from '@/mesh/wormholeIdentityClient';
|
||||
import { gateEnvelopeDisplayText, gateEnvelopeState, isEncryptedGateEnvelope } from '@/mesh/gateEnvelope';
|
||||
import { validateEventPayload } from '@/mesh/meshSchema';
|
||||
import { useGateSSE } from '@/hooks/useGateSSE';
|
||||
|
||||
const GATE_INTROS: Record<string, string> = {
|
||||
infonet:
|
||||
@@ -52,6 +71,8 @@ interface GateViewProps {
|
||||
onNavigateGate: (gate: string) => void;
|
||||
onOpenLiveGate?: (gate: string) => void;
|
||||
availableGates: string[];
|
||||
/** Open the gate shutdown lifecycle view. */
|
||||
onOpenShutdownPetition?: (gate: string) => void;
|
||||
}
|
||||
|
||||
interface GateMessage {
|
||||
@@ -65,6 +86,7 @@ interface GateMessage {
|
||||
sender_ref?: string;
|
||||
format?: string;
|
||||
gate_envelope?: string;
|
||||
envelope_hash?: string;
|
||||
decrypted_message?: string;
|
||||
payload?: {
|
||||
gate?: string;
|
||||
@@ -73,6 +95,7 @@ interface GateMessage {
|
||||
sender_ref?: string;
|
||||
format?: string;
|
||||
gate_envelope?: string;
|
||||
envelope_hash?: string;
|
||||
reply_to?: string;
|
||||
};
|
||||
gate?: string;
|
||||
@@ -93,9 +116,6 @@ interface ReplyContext {
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
const GATE_ACCESS_PROOF_TTL_MS = 45_000;
|
||||
const gateAccessHeaderCache = new Map<string, { headers: Record<string, string>; expiresAt: number }>();
|
||||
|
||||
function timeAgo(timestamp: number): string {
|
||||
const ts = Number(timestamp || 0);
|
||||
if (!ts) return 'just now';
|
||||
@@ -176,44 +196,84 @@ function normalizeGateMessage(message: GateMessage): GateMessage {
|
||||
sender_ref: String(message.sender_ref ?? payload?.sender_ref ?? ''),
|
||||
format: String(message.format ?? payload?.format ?? ''),
|
||||
gate_envelope: String(message.gate_envelope ?? payload?.gate_envelope ?? ''),
|
||||
envelope_hash: String(message.envelope_hash ?? payload?.envelope_hash ?? ''),
|
||||
reply_to: String(message.reply_to ?? payload?.reply_to ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildGateAccessHeaders(gateId: string): Promise<Record<string, string> | undefined> {
|
||||
function describeGateCompatError(detail: string, gateId: string = ''): string {
|
||||
const normalized = String(detail || '').trim();
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (
|
||||
lowered.includes('transport tier insufficient') ||
|
||||
lowered.includes('warming up in the background')
|
||||
) {
|
||||
return 'The obfuscated lane is still warming up in the background. Stay in the room and posting should unlock shortly.';
|
||||
}
|
||||
if (normalized === 'gate_compat_fallback_consent_required') {
|
||||
return 'Local gate runtime is unavailable for this room.';
|
||||
}
|
||||
if (normalized.startsWith('gate_local_runtime_required:')) {
|
||||
const reason = normalized.slice('gate_local_runtime_required:'.length);
|
||||
return `${describeGateCompatReason(reason, gateId)} Use native desktop or resync local gate state.`;
|
||||
}
|
||||
if (normalized === 'gate_backend_plaintext_compat_required') {
|
||||
return 'Service-side gate send is disabled on this runtime. Use native desktop or an explicit compatibility override.';
|
||||
}
|
||||
if (normalized === 'gate_envelope_required') {
|
||||
return 'Local gate sealing is warming up. Your draft is still here.';
|
||||
}
|
||||
if (normalized === 'gate_envelope_encrypt_failed') {
|
||||
return 'Local gate sealing could not finish. Your draft is still here.';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function describeGateCompatConsentPrompt(action: string): string {
|
||||
switch (String(action || '')) {
|
||||
case 'decrypt':
|
||||
return 'Use compatibility mode for this room to read messages on this device.';
|
||||
case 'compose':
|
||||
case 'post':
|
||||
return 'Use compatibility mode for this room to send messages on this device.';
|
||||
default:
|
||||
return 'Use compatibility mode for this room on this device.';
|
||||
}
|
||||
}
|
||||
|
||||
function describeGateCompatReason(reason: string, gateId: string): string {
|
||||
const normalizedGate = String(gateId || '').trim().toLowerCase();
|
||||
if (!normalizedGate) return undefined;
|
||||
const cached = gateAccessHeaderCache.get(normalizedGate);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.headers;
|
||||
const detail = String(reason || '').trim().toLowerCase();
|
||||
if (!detail || detail === 'browser_local_gate_crypto_unavailable') {
|
||||
return 'Local gate crypto failed on this device.';
|
||||
}
|
||||
try {
|
||||
const proof = await controlPlaneJson<{ node_id?: string; ts?: number; proof?: string }>(
|
||||
'/api/wormhole/gate/proof',
|
||||
{
|
||||
requireAdminSession: false,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ gate_id: normalizedGate }),
|
||||
},
|
||||
);
|
||||
const nodeId = String(proof.node_id || '').trim();
|
||||
const gateProof = String(proof.proof || '').trim();
|
||||
const gateTs = String(proof.ts || '').trim();
|
||||
if (!nodeId || !gateProof || !gateTs) return undefined;
|
||||
const headers = {
|
||||
'X-Wormhole-Node-Id': nodeId,
|
||||
'X-Wormhole-Gate-Proof': gateProof,
|
||||
'X-Wormhole-Gate-Ts': gateTs,
|
||||
};
|
||||
gateAccessHeaderCache.set(normalizedGate, {
|
||||
headers,
|
||||
expiresAt: Date.now() + GATE_ACCESS_PROOF_TTL_MS,
|
||||
});
|
||||
return headers;
|
||||
} catch {
|
||||
return undefined;
|
||||
if (detail === 'browser_gate_worker_unavailable') {
|
||||
return 'This runtime cannot use the local gate worker.';
|
||||
}
|
||||
if (detail.startsWith('browser_gate_state_resync_required:')) {
|
||||
return normalizedGate
|
||||
? `Local ${normalizedGate} state needs a resync on this device.`
|
||||
: 'Local gate state needs a resync on this device.';
|
||||
}
|
||||
if (
|
||||
detail.startsWith('browser_gate_state_mapping_missing_group:') ||
|
||||
detail === 'browser_gate_state_active_member_missing'
|
||||
) {
|
||||
return 'Local gate state is incomplete on this device.';
|
||||
}
|
||||
if (detail === 'worker_gate_wrap_key_missing') {
|
||||
return 'Secure local gate storage is unavailable in this browser.';
|
||||
}
|
||||
if (detail === 'gate_mls_decrypt_failed') {
|
||||
return 'Local gate decrypt failed on this device.';
|
||||
}
|
||||
return 'Local gate crypto failed on this device.';
|
||||
}
|
||||
|
||||
interface GateCompatConsentPromptState {
|
||||
gateId: string;
|
||||
action: 'compose' | 'post' | 'decrypt';
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export default function GateView({
|
||||
@@ -224,9 +284,17 @@ export default function GateView({
|
||||
onNavigateGate,
|
||||
onOpenLiveGate: _onOpenLiveGate,
|
||||
availableGates,
|
||||
onOpenShutdownPetition,
|
||||
}: GateViewProps) {
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [messages, setMessages] = useState<GateMessage[]>([]);
|
||||
// Self-authored plaintext, keyed by real event_id returned from the POST.
|
||||
// This lives in React state ONLY — pure RAM, dies with the tab, never
|
||||
// written to disk or sessionStorage. It exists so a refresh that replaces
|
||||
// the messages array with ciphertext from the server doesn't wipe the
|
||||
// author's view of what they just said. MLS's forward-secrecy property
|
||||
// (sender can't re-decrypt own output) is preserved on the wire / on disk.
|
||||
const [selfAuthoredByEventId, setSelfAuthoredByEventId] = useState<Record<string, string>>({});
|
||||
const [composer, setComposer] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [roomError, setRoomError] = useState('');
|
||||
@@ -236,45 +304,116 @@ export default function GateView({
|
||||
const [reps, setReps] = useState<Record<string, number>>({});
|
||||
const [voteNotice, setVoteNotice] = useState('');
|
||||
const [votedOn, setVotedOn] = useState<Record<string, 1 | -1>>({});
|
||||
const [compatActive, setCompatActive] = useState(false);
|
||||
const [compatConsentPrompt, setCompatConsentPrompt] = useState<GateCompatConsentPromptState | null>(null);
|
||||
const [streamStatus, setStreamStatus] = useState(() => getGateSessionStreamStatus());
|
||||
const [streamStatusHydrated, setStreamStatusHydrated] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const pollTimerRef = useRef<number | null>(null);
|
||||
const waitAbortRef = useRef<AbortController | null>(null);
|
||||
const gateCursorRef = useRef(0);
|
||||
const repsRef = useRef<Record<string, number>>({});
|
||||
const streamEnabledForGateRef = useRef(false);
|
||||
|
||||
const gateId = useMemo(() => String(gateName || '').trim().toLowerCase(), [gateName]);
|
||||
const introMessage =
|
||||
GATE_INTROS[gateId] || 'Welcome to this gate. Be civil. The Shadowbroker is watching.';
|
||||
|
||||
useEffect(() => {
|
||||
setCompatActive(hasGateCompatFallbackApproval(gateId));
|
||||
setCompatConsentPrompt(null);
|
||||
gateCursorRef.current = 0;
|
||||
}, [gateId]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeGateSessionStreamStatus((nextStatus) => {
|
||||
setStreamStatus(nextStatus);
|
||||
setStreamStatusHydrated(true);
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gateId || !status?.has_local_access) {
|
||||
return;
|
||||
}
|
||||
return retainGateSessionStreamGate(gateId);
|
||||
}, [gateId, status?.has_local_access]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gateId || !status?.has_local_access) {
|
||||
return;
|
||||
}
|
||||
void prepareWormholeInteractiveLane({
|
||||
minimumTransportTier: 'private_control_only',
|
||||
}).catch(() => undefined);
|
||||
}, [gateId, status?.has_local_access]);
|
||||
|
||||
const streamEnabledForGate =
|
||||
Boolean(gateId) &&
|
||||
streamStatus.phase === 'open' &&
|
||||
streamStatus.subscriptions.includes(gateId);
|
||||
const streamPreferredForGate =
|
||||
Boolean(gateId) &&
|
||||
(streamStatus.phase === 'connecting' || streamStatus.phase === 'open') &&
|
||||
streamStatus.subscriptions.includes(gateId);
|
||||
|
||||
useEffect(() => {
|
||||
streamEnabledForGateRef.current = streamPreferredForGate;
|
||||
}, [streamPreferredForGate]);
|
||||
|
||||
const searchMatch = searchInput.startsWith('g/')
|
||||
? availableGates.find((g) => g.startsWith(searchInput.slice(2).toLowerCase()))
|
||||
: null;
|
||||
|
||||
const voteScopeKey = useCallback((targetId: string) => `${gateId}::${String(targetId || '').trim()}`, [gateId]);
|
||||
|
||||
const hydrateMessages = useCallback(async (rawMessages: GateMessage[]): Promise<GateMessage[]> => {
|
||||
const hydrateMessages = useCallback(async (
|
||||
rawMessages: GateMessage[],
|
||||
): Promise<{ messages: GateMessage[]; compatDecryptBlocked: boolean; roomError?: string }> => {
|
||||
const baseMessages = (Array.isArray(rawMessages) ? rawMessages : []).map(normalizeGateMessage);
|
||||
const encrypted = baseMessages
|
||||
.map((message, index) => ({ message, index }))
|
||||
.filter(({ message }) => isEncryptedGateEnvelope(message));
|
||||
|
||||
if (!encrypted.length) {
|
||||
return baseMessages.map((message) => ({ ...message, decrypted_message: '' }));
|
||||
return {
|
||||
messages: baseMessages.map((message) => ({ ...message, decrypted_message: '' })),
|
||||
compatDecryptBlocked: false,
|
||||
roomError: '',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const batch = await decryptWormholeGateMessages(
|
||||
encrypted.map(({ message }) => ({
|
||||
gate_id: String(message.gate || gateId),
|
||||
epoch: Number(message.epoch || 0),
|
||||
ciphertext: String(message.ciphertext || ''),
|
||||
nonce: String(message.nonce || ''),
|
||||
sender_ref: String(message.sender_ref || ''),
|
||||
format: String(message.format || 'mls1'),
|
||||
gate_envelope: String(message.gate_envelope || ''),
|
||||
})),
|
||||
encrypted.map(({ message }) => {
|
||||
const gateEnvelope = String(message.gate_envelope || '');
|
||||
return {
|
||||
gate_id: String(message.gate || gateId),
|
||||
epoch: Number(message.epoch || 0),
|
||||
ciphertext: String(message.ciphertext || ''),
|
||||
nonce: String(message.nonce || ''),
|
||||
sender_ref: String(message.sender_ref || ''),
|
||||
format: String(message.format || 'mls1'),
|
||||
gate_envelope: gateEnvelope,
|
||||
envelope_hash: String(message.envelope_hash || ''),
|
||||
// If a gate_envelope is present, go straight to the backend
|
||||
// envelope-fast-path by signaling recovery_envelope=true.
|
||||
// This skips browser-side MLS (which has empty state across
|
||||
// fresh anon sessions) and uses the durable AES-GCM envelope
|
||||
// keyed under gate_secret — which EVERY gate member can
|
||||
// decrypt as long as they hold the current gate_secret.
|
||||
recovery_envelope: gateEnvelope.length > 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const results = Array.isArray(batch.results) ? batch.results : [];
|
||||
const nextMessages = [...baseMessages];
|
||||
encrypted.forEach(({ index, message }, resultIndex) => {
|
||||
const decrypted = results[resultIndex];
|
||||
const decryptedReplyTo = decrypted?.ok ? String(decrypted.reply_to || '').trim() : '';
|
||||
nextMessages[index] = {
|
||||
...message,
|
||||
decrypted_message: decrypted?.ok
|
||||
@@ -285,43 +424,45 @@ export default function GateView({
|
||||
: String(decrypted.plaintext || ''))
|
||||
: '',
|
||||
epoch: decrypted?.ok ? Number(decrypted.epoch || message.epoch || 0) : message.epoch,
|
||||
reply_to: decryptedReplyTo || String(message.reply_to || ''),
|
||||
};
|
||||
});
|
||||
return nextMessages;
|
||||
} catch {
|
||||
return baseMessages.map((message) => ({ ...message, decrypted_message: '' }));
|
||||
return {
|
||||
messages: nextMessages,
|
||||
compatDecryptBlocked: false,
|
||||
roomError: '',
|
||||
};
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : '';
|
||||
if (
|
||||
detail === 'gate_compat_fallback_consent_required' ||
|
||||
detail.startsWith('gate_local_runtime_required:')
|
||||
) {
|
||||
return {
|
||||
messages: baseMessages.map((message) => ({ ...message, decrypted_message: '' })),
|
||||
compatDecryptBlocked: false,
|
||||
roomError: describeGateCompatError(detail, gateId),
|
||||
};
|
||||
}
|
||||
return {
|
||||
messages: baseMessages.map((message) => ({ ...message, decrypted_message: '' })),
|
||||
compatDecryptBlocked: false,
|
||||
roomError: '',
|
||||
};
|
||||
}
|
||||
}, [gateId]);
|
||||
|
||||
const refreshGate = useCallback(async () => {
|
||||
if (!gateId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const nextStatus = await fetchWormholeGateKeyStatus(gateId);
|
||||
setStatus(nextStatus);
|
||||
if (!nextStatus?.ok || !nextStatus.has_local_access) {
|
||||
setMessages([]);
|
||||
setRoomError(String(nextStatus?.detail || 'Gate access still syncing'));
|
||||
return;
|
||||
}
|
||||
const headers = await buildGateAccessHeaders(gateId);
|
||||
if (!headers) {
|
||||
setMessages([]);
|
||||
setRoomError('Gate proof unavailable');
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({ limit: '40', gate: gateId });
|
||||
const res = await fetch(`${API_BASE}/api/mesh/infonet/messages?${params}`, { headers });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setMessages([]);
|
||||
setRoomError(String(data?.detail || 'Failed to load gate room'));
|
||||
return;
|
||||
}
|
||||
const hydrated = await hydrateMessages(Array.isArray(data.messages) ? data.messages : []);
|
||||
const chronological = [...hydrated].reverse();
|
||||
const applyGateMessages = useCallback(
|
||||
async (rawMessages: GateMessage[]) => {
|
||||
const normalizedMessages = Array.isArray(rawMessages) ? rawMessages : [];
|
||||
const hydrated = await hydrateMessages(normalizedMessages);
|
||||
const chronological = [...hydrated.messages].reverse();
|
||||
setMessages(chronological);
|
||||
setRoomError('');
|
||||
if (hydrated.roomError) {
|
||||
setRoomError(hydrated.roomError);
|
||||
} else if (!hydrated.compatDecryptBlocked) {
|
||||
setRoomError('');
|
||||
}
|
||||
|
||||
const uniqueEventIds = Array.from(
|
||||
new Set(
|
||||
@@ -332,14 +473,20 @@ export default function GateView({
|
||||
);
|
||||
if (uniqueEventIds.length > 0) {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
for (const eid of uniqueEventIds) params.append('node_id', eid);
|
||||
const repRes = await fetch(`${API_BASE}/api/mesh/reputation/batch?${params}`);
|
||||
if (repRes.ok) {
|
||||
const repData = await repRes.json();
|
||||
const freshReps: Record<string, number> = {};
|
||||
if (repData.reputations && typeof repData.reputations === 'object') {
|
||||
for (const [k, v] of Object.entries(repData.reputations)) {
|
||||
const uncachedEventIds = uniqueEventIds.filter(
|
||||
(eventId) => !Object.prototype.hasOwnProperty.call(repsRef.current, eventId),
|
||||
);
|
||||
if (uncachedEventIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
for (const eid of uncachedEventIds) params.append('node_id', eid);
|
||||
const repRes = await fetch(`${API_BASE}/api/mesh/reputation/batch?${params}`);
|
||||
if (repRes.ok) {
|
||||
const repData = await repRes.json();
|
||||
const freshReps: Record<string, number> = {};
|
||||
if (repData.reputations && typeof repData.reputations === 'object') {
|
||||
for (const [k, v] of Object.entries(repData.reputations)) {
|
||||
freshReps[k] = Number(v || 0);
|
||||
}
|
||||
}
|
||||
@@ -351,32 +498,246 @@ export default function GateView({
|
||||
/* ignore batch rep fetch failure */
|
||||
}
|
||||
}
|
||||
},
|
||||
[hydrateMessages],
|
||||
);
|
||||
|
||||
const refreshGate = useCallback(async (options: { force?: boolean } = {}): Promise<boolean> => {
|
||||
if (!gateId) return false;
|
||||
setLoading(true);
|
||||
try {
|
||||
const streamOwned = streamEnabledForGateRef.current;
|
||||
const nextStatus = await fetchWormholeGateKeyStatus(gateId, {
|
||||
force: options.force,
|
||||
mode: streamOwned ? 'session_stream' : 'active_room',
|
||||
});
|
||||
setStatus(nextStatus);
|
||||
if (!nextStatus?.ok || !nextStatus.has_local_access) {
|
||||
gateCursorRef.current = 0;
|
||||
setMessages([]);
|
||||
setRoomError(String(nextStatus?.detail || 'Gate access still syncing'));
|
||||
return false;
|
||||
}
|
||||
if (options.force || !streamOwned || !status?.has_local_access) {
|
||||
await syncBrowserWormholeGateState(gateId).catch(() => false);
|
||||
}
|
||||
const snapshot = await fetchGateMessageSnapshotState(gateId, ACTIVE_GATE_ROOM_MESSAGE_LIMIT, {
|
||||
force: options.force,
|
||||
proofMode: streamOwned ? 'session_stream' : 'default',
|
||||
});
|
||||
gateCursorRef.current = snapshot.cursor;
|
||||
await applyGateMessages(snapshot.messages as GateMessage[]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setRoomError(error instanceof Error ? error.message : 'Failed to load gate room');
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [gateId, hydrateMessages]);
|
||||
}, [applyGateMessages, gateId, status?.has_local_access]);
|
||||
|
||||
// SSE: instant delivery when new gate events arrive
|
||||
const handleSSEEvent = useCallback(
|
||||
(eventGateId: string) => {
|
||||
if (eventGateId === gateId) void refreshGate();
|
||||
},
|
||||
[gateId, refreshGate],
|
||||
);
|
||||
useGateSSE(handleSSEEvent);
|
||||
|
||||
// Fallback poll (30s) in case SSE disconnects
|
||||
useEffect(() => {
|
||||
void refreshGate();
|
||||
const timer = window.setInterval(() => {
|
||||
void refreshGate();
|
||||
}, 30_000);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
if (!gateId || !status?.has_local_access || !streamEnabledForGate) {
|
||||
return;
|
||||
}
|
||||
return subscribeGateSessionStreamEvents((event) => {
|
||||
if (event.event !== 'gate_update' || !event.data || typeof event.data !== 'object') {
|
||||
return;
|
||||
}
|
||||
const updates = Array.isArray((event.data as { updates?: unknown }).updates)
|
||||
? ((event.data as { updates?: Array<{ gate_id?: string; cursor?: number }> }).updates || [])
|
||||
: [];
|
||||
const matching = updates.find(
|
||||
(update) => String(update?.gate_id || '').trim().toLowerCase() === gateId,
|
||||
);
|
||||
if (!matching) {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const snapshot = await fetchGateMessageSnapshotState(
|
||||
gateId,
|
||||
ACTIVE_GATE_ROOM_MESSAGE_LIMIT,
|
||||
{ force: true, proofMode: 'session_stream' },
|
||||
);
|
||||
gateCursorRef.current = snapshot.cursor;
|
||||
await applyGateMessages(snapshot.messages as GateMessage[]);
|
||||
} catch {
|
||||
await refreshGate({ force: true });
|
||||
}
|
||||
})();
|
||||
});
|
||||
}, [applyGateMessages, gateId, refreshGate, status?.has_local_access, streamEnabledForGate]);
|
||||
|
||||
// Active gate rooms now wait for server-side change instead of issuing a fresh fetch on every cycle.
|
||||
useEffect(() => {
|
||||
if (!streamStatusHydrated) {
|
||||
return;
|
||||
}
|
||||
const isLiveStreamPreferredForGate = () => {
|
||||
const liveStreamStatus = getGateSessionStreamStatus();
|
||||
return (
|
||||
Boolean(gateId) &&
|
||||
(liveStreamStatus.phase === 'connecting' || liveStreamStatus.phase === 'open') &&
|
||||
liveStreamStatus.subscriptions.includes(gateId)
|
||||
);
|
||||
};
|
||||
}, [refreshGate]);
|
||||
const liveStreamPreferred = streamPreferredForGate || isLiveStreamPreferredForGate();
|
||||
streamEnabledForGateRef.current = liveStreamPreferred;
|
||||
let cancelled = false;
|
||||
const clearRetry = () => {
|
||||
if (pollTimerRef.current) {
|
||||
window.clearTimeout(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRetry = () => {
|
||||
if (cancelled || streamEnabledForGateRef.current) return;
|
||||
clearRetry();
|
||||
pollTimerRef.current = window.setTimeout(() => {
|
||||
pollTimerRef.current = null;
|
||||
void waitForNextChange();
|
||||
}, nextGateMessagesPollDelayMs());
|
||||
};
|
||||
|
||||
const startWaitIfNeeded = () => {
|
||||
queueMicrotask(() => {
|
||||
streamEnabledForGateRef.current =
|
||||
streamPreferredForGate || isLiveStreamPreferredForGate();
|
||||
if (!cancelled && !streamEnabledForGateRef.current) {
|
||||
void waitForNextChange();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const waitForNextChange = async () => {
|
||||
streamEnabledForGateRef.current =
|
||||
streamPreferredForGate || isLiveStreamPreferredForGate();
|
||||
if (cancelled || !gateId || streamEnabledForGateRef.current) return;
|
||||
const controller = new AbortController();
|
||||
waitAbortRef.current = controller;
|
||||
try {
|
||||
const snapshot = await waitForGateMessageSnapshot(
|
||||
gateId,
|
||||
gateCursorRef.current,
|
||||
ACTIVE_GATE_ROOM_MESSAGE_LIMIT,
|
||||
{
|
||||
timeoutMs: nextGateMessagesWaitTimeoutMs(),
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
waitAbortRef.current = null;
|
||||
if (cancelled) return;
|
||||
gateCursorRef.current = snapshot.cursor;
|
||||
if (snapshot.changed) {
|
||||
await applyGateMessages(snapshot.messages as GateMessage[]);
|
||||
void waitForNextChange();
|
||||
return;
|
||||
}
|
||||
clearRetry();
|
||||
pollTimerRef.current = window.setTimeout(() => {
|
||||
pollTimerRef.current = null;
|
||||
void waitForNextChange();
|
||||
}, nextGateMessagesWaitRearmDelayMs());
|
||||
} catch (error) {
|
||||
waitAbortRef.current = null;
|
||||
if (cancelled || controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const ready = await refreshGate({ force: true });
|
||||
if (!ready) {
|
||||
setRoomError(error instanceof Error ? error.message : 'Failed to load gate room');
|
||||
scheduleRetry();
|
||||
return;
|
||||
}
|
||||
startWaitIfNeeded();
|
||||
}
|
||||
};
|
||||
|
||||
if (liveStreamPreferred) {
|
||||
void refreshGate();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearRetry();
|
||||
if (waitAbortRef.current) {
|
||||
waitAbortRef.current.abort();
|
||||
waitAbortRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
void refreshGate().then((ready) => {
|
||||
streamEnabledForGateRef.current =
|
||||
streamPreferredForGate || isLiveStreamPreferredForGate();
|
||||
if (!cancelled && ready && !streamEnabledForGateRef.current) {
|
||||
startWaitIfNeeded();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearRetry();
|
||||
if (waitAbortRef.current) {
|
||||
waitAbortRef.current.abort();
|
||||
waitAbortRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [applyGateMessages, gateId, refreshGate, streamPreferredForGate, streamStatusHydrated]);
|
||||
|
||||
useEffect(() => {
|
||||
setCompatConsentPrompt(null);
|
||||
}, [gateId]);
|
||||
|
||||
useEffect(() => {
|
||||
repsRef.current = reps;
|
||||
}, [reps]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCompatFallback = (event: Event) => {
|
||||
const detail =
|
||||
event instanceof CustomEvent && event.detail && typeof event.detail === 'object'
|
||||
? (event.detail as { gateId?: string; action?: string })
|
||||
: {};
|
||||
const eventGateId = String(detail.gateId || '').trim().toLowerCase();
|
||||
if (!eventGateId || eventGateId !== gateId) {
|
||||
return;
|
||||
}
|
||||
setCompatActive(true);
|
||||
};
|
||||
window.addEventListener('sb:gate-compat-fallback', handleCompatFallback as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('sb:gate-compat-fallback', handleCompatFallback as EventListener);
|
||||
};
|
||||
}, [gateId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCompatConsentRequired = (event: Event) => {
|
||||
const detail =
|
||||
event instanceof CustomEvent && event.detail && typeof event.detail === 'object'
|
||||
? (event.detail as GateCompatConsentPromptState)
|
||||
: null;
|
||||
const eventGateId = String(detail?.gateId || '').trim().toLowerCase();
|
||||
if (!eventGateId || eventGateId !== gateId || !detail) {
|
||||
return;
|
||||
}
|
||||
setCompatConsentPrompt({
|
||||
gateId: eventGateId,
|
||||
action: detail.action,
|
||||
reason: String(detail.reason || ''),
|
||||
});
|
||||
};
|
||||
window.addEventListener(
|
||||
'sb:gate-compat-consent-required',
|
||||
handleCompatConsentRequired as EventListener,
|
||||
);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'sb:gate-compat-consent-required',
|
||||
handleCompatConsentRequired as EventListener,
|
||||
);
|
||||
};
|
||||
}, [gateId]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
@@ -405,27 +766,31 @@ export default function GateView({
|
||||
setBusy(true);
|
||||
setRoomError('');
|
||||
try {
|
||||
await controlPlaneJson<{ ok: boolean; detail?: string }>('/api/wormhole/gate/message/post', {
|
||||
requireAdminSession: false,
|
||||
capabilityIntent: 'wormhole_gate_content',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gate_id: gateId,
|
||||
plaintext: msg,
|
||||
reply_to: replyContext?.eventId || '',
|
||||
}),
|
||||
});
|
||||
const gatePost = await postWormholeGateMessage(gateId, msg, replyContext?.eventId || '').catch((error) => ({
|
||||
ok: false,
|
||||
detail: error instanceof Error ? error.message : 'Gate post failed',
|
||||
}));
|
||||
if (gatePost?.ok === false) {
|
||||
throw new Error(describeGateCompatError(String(gatePost.detail || 'Gate post failed'), gateId));
|
||||
}
|
||||
setComposer('');
|
||||
setReplyContext(null);
|
||||
// Optimistic: append a placeholder message so the user sees it immediately,
|
||||
// then let the next poll cycle (8s) hydrate it with the real encrypted copy.
|
||||
// Capture the server-assigned event_id and remember the plaintext we
|
||||
// just authored, keyed by that event_id. The refresh will bring back
|
||||
// the same event as ciphertext; during render we paint over its
|
||||
// decrypted_message with what we typed. Pure React state — when the
|
||||
// tab closes, this map vanishes.
|
||||
const realEventId = String((gatePost as { event_id?: string })?.event_id || '');
|
||||
if (realEventId) {
|
||||
setSelfAuthoredByEventId((prev) => ({ ...prev, [realEventId]: msg }));
|
||||
}
|
||||
// Optimistic placeholder so the post appears instantly even before
|
||||
// the next refresh round-trip completes. Uses the real event_id when
|
||||
// available so the refresh merges cleanly rather than duplicating.
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
event_id: `_pending_${Date.now()}`,
|
||||
event_id: realEventId || `_pending_${Date.now()}`,
|
||||
message: msg,
|
||||
decrypted_message: msg,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
@@ -435,8 +800,6 @@ export default function GateView({
|
||||
ephemeral: true,
|
||||
} as GateMessage,
|
||||
]);
|
||||
// Non-blocking background refresh to pick up the real message
|
||||
void refreshGate();
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : 'Gate post failed';
|
||||
// Suppress technical sequence/replay errors — just show a clean retry hint
|
||||
@@ -448,7 +811,21 @@ export default function GateView({
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, composer, gateId, persona, refreshGate, replyContext, status?.has_local_access]);
|
||||
}, [busy, composer, gateId, persona, replyContext, status?.has_local_access]);
|
||||
|
||||
const approveCompatFallback = useCallback(() => {
|
||||
if (!compatConsentPrompt?.gateId) return;
|
||||
approveGateCompatFallback(compatConsentPrompt.gateId);
|
||||
const action = compatConsentPrompt.action;
|
||||
setCompatActive(true);
|
||||
setCompatConsentPrompt(null);
|
||||
setRoomError('');
|
||||
if (action === 'decrypt') {
|
||||
void refreshGate({ force: true });
|
||||
return;
|
||||
}
|
||||
void handleSend();
|
||||
}, [compatConsentPrompt, handleSend, refreshGate]);
|
||||
|
||||
const handleVote = useCallback(async (eventId: string, vote: 1 | -1) => {
|
||||
if (!eventId || !gateId || votedOn[voteScopeKey(eventId)] === vote) return;
|
||||
@@ -503,19 +880,45 @@ export default function GateView({
|
||||
}
|
||||
}, [gateId, voteScopeKey, votedOn]);
|
||||
|
||||
const threadedMessages = useMemo(() => buildThreadedList(messages), [messages]);
|
||||
// Overlay self-authored plaintexts onto the refreshed message list.
|
||||
// Lives only in this component's React state; a tab close wipes it.
|
||||
const messagesWithSelfOverlay = useMemo(
|
||||
() =>
|
||||
messages.map((m) => {
|
||||
const eid = String(m.event_id || '');
|
||||
const selfText = eid ? selfAuthoredByEventId[eid] : '';
|
||||
if (!selfText) return m;
|
||||
return { ...m, decrypted_message: selfText };
|
||||
}),
|
||||
[messages, selfAuthoredByEventId],
|
||||
);
|
||||
const threadedMessages = useMemo(
|
||||
() => buildThreadedList(messagesWithSelfOverlay),
|
||||
[messagesWithSelfOverlay],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div className="border-b border-gray-800 pb-4 mb-4 shrink-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-500 hover:text-cyan-400 transition-all uppercase text-xs tracking-widest border border-cyan-900/50 px-3 py-1 bg-cyan-900/10 hover:bg-cyan-900/30 hover:border-cyan-500/50"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
RETURN TO MAIN
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-500 hover:text-cyan-400 transition-all uppercase text-xs tracking-widest border border-cyan-900/50 px-3 py-1 bg-cyan-900/10 hover:bg-cyan-900/30 hover:border-cyan-500/50"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
RETURN TO MAIN
|
||||
</button>
|
||||
{onOpenShutdownPetition && (
|
||||
<button
|
||||
onClick={() => onOpenShutdownPetition(gateName)}
|
||||
title="Open gate shutdown lifecycle (suspend / shutdown / appeal)"
|
||||
className="flex items-center text-amber-500 hover:text-amber-400 transition-all uppercase text-xs tracking-widest border border-amber-900/50 px-3 py-1 bg-amber-900/10 hover:bg-amber-900/30 hover:border-amber-500/50"
|
||||
>
|
||||
SHUTDOWN STATUS
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs">
|
||||
LOGGED IN AS:{' '}
|
||||
<span
|
||||
@@ -530,11 +933,18 @@ export default function GateView({
|
||||
|
||||
<div className="flex items-center justify-between gap-4 mt-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-cyan-400 uppercase tracking-widest">g/{gateId}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold text-cyan-400 uppercase tracking-widest">g/{gateId}</h1>
|
||||
{compatActive ? (
|
||||
<span className="border border-amber-500/40 bg-amber-950/20 px-2 py-0.5 text-[10px] font-mono tracking-[0.2em] text-amber-200">
|
||||
COMPAT
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm mt-1">Fixed obfuscated gate. Creation is disabled for this testnet.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void refreshGate()}
|
||||
onClick={() => void refreshGate({ force: true })}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 border border-cyan-500/30 bg-cyan-950/20 text-cyan-300 hover:bg-cyan-900/30 transition-colors text-sm uppercase tracking-[0.22em]"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
@@ -593,11 +1003,31 @@ export default function GateView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{roomError ? (
|
||||
{roomError && !compatConsentPrompt ? (
|
||||
<div className="mb-3 shrink-0 border border-red-900/30 bg-red-950/10 px-3 py-2 text-[11px] text-red-300">
|
||||
{roomError}
|
||||
</div>
|
||||
) : null}
|
||||
{compatConsentPrompt ? (
|
||||
<div className="mb-3 shrink-0 border border-amber-500/30 bg-amber-950/15 px-3 py-2 text-[11px] text-amber-100/90">
|
||||
<div className="text-[12px] font-mono tracking-[0.2em] text-amber-300">COMPAT MODE</div>
|
||||
<div className="mt-1 leading-[1.7]">
|
||||
{describeGateCompatConsentPrompt(compatConsentPrompt.action)}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-amber-200/70">
|
||||
{describeGateCompatReason(compatConsentPrompt.reason, compatConsentPrompt.gateId)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={approveCompatFallback}
|
||||
className="px-3 py-1.5 border border-amber-500/40 bg-amber-950/20 text-[11px] font-mono tracking-[0.18em] text-amber-100 hover:bg-amber-900/30 transition-colors"
|
||||
>
|
||||
ENABLE FOR ROOM
|
||||
</button>
|
||||
<span className="text-[11px] text-amber-200/70">Weaker privacy on this device.</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{voteNotice ? (
|
||||
<div className="mb-2 shrink-0 border border-yellow-800/30 bg-yellow-950/10 px-3 py-1.5 text-sm text-yellow-400/80 font-mono">
|
||||
{voteNotice}
|
||||
@@ -644,10 +1074,14 @@ export default function GateView({
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm font-mono">
|
||||
<span className="text-green-400" title={String(message.public_key || message.node_id || '')}>
|
||||
@{String(message.node_id || '').replace(/^!sb_/, '').slice(0, 8)
|
||||
|| String(message.public_key || '').slice(0, 8)
|
||||
|| 'unknown'}
|
||||
<span className="text-green-400">
|
||||
@{String(
|
||||
(message as unknown as { sender_handle?: string }).sender_handle
|
||||
|| ((message as unknown as { payload?: { sender_handle?: string } }).payload?.sender_handle)
|
||||
|| String(message.node_id || '').replace(/^!sb_/, '').slice(0, 8)
|
||||
|| String(message.public_key || '').slice(0, 8)
|
||||
|| 'anon_????',
|
||||
)}
|
||||
</span>
|
||||
{isEncryptedGateEnvelope(message) ? (
|
||||
<span
|
||||
@@ -657,7 +1091,7 @@ export default function GateView({
|
||||
: 'text-amber-300 border-amber-700/60'
|
||||
}`}
|
||||
>
|
||||
{gateEnvelopeState(message) === 'decrypted' ? 'DECRYPTED' : 'KEY LOCKED'}
|
||||
{gateEnvelopeState(message) === 'decrypted' ? 'DECRYPTED' : 'SEALED'}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-[var(--text-muted)] text-[13px]">{timeAgo(message.timestamp)}</span>
|
||||
@@ -742,7 +1176,12 @@ export default function GateView({
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={composer}
|
||||
onChange={(e) => setComposer(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setComposer(e.target.value);
|
||||
if (roomError) {
|
||||
setRoomError('');
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Terminal, Radio, Globe, Key, LogOut, Activity, Vote, User, ArrowRightLeft, Briefcase, Mail } from 'lucide-react';
|
||||
import { Terminal, Radio, Globe, Key, LogOut, Activity, Vote, User, ArrowRightLeft, Briefcase, Mail, Brain, GitBranch, Cpu, KeyRound } from 'lucide-react';
|
||||
import { getNodeIdentity, getWormholeIdentityDescriptor } from '@/mesh/meshIdentity';
|
||||
import {
|
||||
activateWormholeGatePersona,
|
||||
@@ -19,6 +19,13 @@ import WeatherWidget from './WeatherWidget';
|
||||
import TrendingPosts from './TrendingPosts';
|
||||
import HashchainEvents from './HashchainEvents';
|
||||
import NetworkStats from './NetworkStats';
|
||||
import AIQueryView from './AIQueryView';
|
||||
import PetitionsView from './PetitionsView';
|
||||
import UpgradeView from './UpgradeView';
|
||||
import ResolutionView from './ResolutionView';
|
||||
import GateShutdownView from './GateShutdownView';
|
||||
import BootstrapView from './BootstrapView';
|
||||
import FunctionKeyView from './FunctionKeyView';
|
||||
|
||||
|
||||
const ASCII_HEADER = `
|
||||
@@ -38,11 +45,8 @@ const ASCII_HEADER = `
|
||||
`;
|
||||
|
||||
const COMING_SOON_MODULES: Record<string, { title: string; desc: string; status: string }> = {
|
||||
BALLOT: {
|
||||
title: 'BALLOT — DEMOCRACY FOR ALL SOON',
|
||||
desc: 'Governance surfaces are not live in this testnet shell yet. When they arrive, they should reflect real community demand, clear rules, and verifiable participation instead of placeholder politics.',
|
||||
status: 'MODULE STATUS: HOLDING SCREEN ONLY — NO LIVE BALLOTS OR COUNTS',
|
||||
},
|
||||
// BALLOT entry removed 2026-04-28: the BALLOT command now navigates
|
||||
// to PetitionsView (live governance DSL + petition lifecycle).
|
||||
GIGS: {
|
||||
title: 'GIGS — NETWORK BOUNTIES',
|
||||
desc: 'Decentralized work contracts, intelligence bounties, and mesh task allocation. Accept jobs, deliver payloads, and earn credits through verified proof-of-work completion.',
|
||||
@@ -60,6 +64,19 @@ const GATES = [
|
||||
'ukraine-front', 'iran-front', 'world-news', 'prediction-markets',
|
||||
'finance', 'cryptography', 'cryptocurrencies', 'meet-chat', 'opsec-lab'
|
||||
];
|
||||
const GATE_LAUNCH_RETRY_DELAY_MS = 3000;
|
||||
const GATE_LAUNCH_RETRY_ATTEMPTS = 20;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function isGateLaneStartingError(detail: string): boolean {
|
||||
const lowered = String(detail || '').trim().toLowerCase();
|
||||
return lowered.includes('obfuscated lane is still starting');
|
||||
}
|
||||
|
||||
const SHELL_ANON_PERSONAS_KEY = 'sb_infonet_shell_anon_personas';
|
||||
|
||||
@@ -99,7 +116,11 @@ function allocateShellAnonPersona(): string {
|
||||
|
||||
const SECTIONS = [
|
||||
{ name: 'HELP', icon: <Terminal size={14} className="mr-2" /> },
|
||||
{ name: 'AI', icon: <Brain size={14} className="mr-2" /> },
|
||||
{ name: 'BALLOT', icon: <Vote size={14} className="mr-2" /> },
|
||||
{ name: 'UPGRADES', icon: <GitBranch size={14} className="mr-2" /> },
|
||||
{ name: 'BOOTSTRAP', icon: <Cpu size={14} className="mr-2" /> },
|
||||
{ name: 'F-KEYS', icon: <KeyRound size={14} className="mr-2" /> },
|
||||
{ name: 'GIGS', icon: <Briefcase size={14} className="mr-2" /> },
|
||||
{ name: 'MESH', icon: <Globe size={14} className="mr-2" /> },
|
||||
{ name: 'GATES', icon: <Key size={14} className="mr-2" /> },
|
||||
@@ -119,16 +140,26 @@ interface InfonetShellProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpenLiveGate?: (gate: string) => void;
|
||||
onOpenDeadDrop?: (peerId: string, options?: { showSas?: boolean }) => void;
|
||||
}
|
||||
|
||||
export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: InfonetShellProps) {
|
||||
export default function InfonetShell({
|
||||
isOpen,
|
||||
onClose,
|
||||
onOpenLiveGate,
|
||||
onOpenDeadDrop,
|
||||
}: InfonetShellProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [history, setHistory] = useState<CommandHistory[]>([]);
|
||||
const [isBooting, setIsBooting] = useState(true);
|
||||
const [bootText, setBootText] = useState<string[]>([]);
|
||||
|
||||
// Navigation & State
|
||||
const [currentView, setCurrentView] = useState<'terminal' | 'gate' | 'market' | 'profile' | 'messages'>('terminal');
|
||||
type ViewName =
|
||||
| 'terminal' | 'gate' | 'market' | 'profile' | 'messages' | 'ai'
|
||||
| 'petitions' | 'upgrades' | 'resolution' | 'gate-shutdown'
|
||||
| 'bootstrap' | 'function-keys';
|
||||
const [currentView, setCurrentView] = useState<ViewName>('terminal');
|
||||
const [activeGate, setActiveGate] = useState<string | null>(null);
|
||||
const [persona, setPersona] = useState<string | null>(null);
|
||||
const [activeGateMode, setActiveGateMode] = useState<'anonymous' | 'persona' | null>(null);
|
||||
@@ -137,10 +168,15 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
const [isCitizen] = useState(false);
|
||||
const [comingSoonModule, setComingSoonModule] = useState<string | null>(null);
|
||||
const [wormholePromptKey, setWormholePromptKey] = useState('');
|
||||
// Targets for parameterized economy views.
|
||||
const [resolutionMarketId, setResolutionMarketId] = useState<string | null>(null);
|
||||
const [shutdownGateId, setShutdownGateId] = useState<string | null>(null);
|
||||
const [bootstrapMarketId, setBootstrapMarketId] = useState<string | null>(null);
|
||||
|
||||
const endOfTerminalRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const gateLaunchAttemptRef = useRef(0);
|
||||
|
||||
// Real mesh identity
|
||||
const nodeIdentity = useMemo(() => getNodeIdentity(), []);
|
||||
@@ -167,6 +203,7 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
setInputMode('normal');
|
||||
setPendingGate(null);
|
||||
setInput('');
|
||||
gateLaunchAttemptRef.current += 1;
|
||||
setIsBooting(true);
|
||||
setBootText([]);
|
||||
|
||||
@@ -235,7 +272,7 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
endOfTerminalRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [history]);
|
||||
|
||||
const handleNavigate = (view: 'terminal' | 'gate' | 'market' | 'profile' | 'messages', gate?: string) => {
|
||||
const handleNavigate = (view: 'terminal' | 'gate' | 'market' | 'profile' | 'messages' | 'ai', gate?: string) => {
|
||||
if (view === 'gate' && gate) {
|
||||
if (onOpenLiveGate) {
|
||||
setPendingGate(gate);
|
||||
@@ -262,6 +299,58 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
setCurrentView(view);
|
||||
};
|
||||
|
||||
const openGateWhenReady = async (
|
||||
gateTarget: string,
|
||||
operation: () => Promise<void>,
|
||||
options: { commandLabel: string; waitingOutput: React.ReactNode; failurePrefix: string },
|
||||
) => {
|
||||
const launchId = ++gateLaunchAttemptRef.current;
|
||||
let waitingShown = false;
|
||||
for (let attempt = 0; attempt < GATE_LAUNCH_RETRY_ATTEMPTS; attempt += 1) {
|
||||
if (gateLaunchAttemptRef.current !== launchId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await operation();
|
||||
return;
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : options.failurePrefix;
|
||||
if (!isGateLaneStartingError(detail)) {
|
||||
if (gateLaunchAttemptRef.current !== launchId) {
|
||||
return;
|
||||
}
|
||||
setHistory(prev => [...prev, {
|
||||
command: options.commandLabel,
|
||||
output: <span className="text-red-400">ERR: {detail}</span>,
|
||||
}]);
|
||||
return;
|
||||
}
|
||||
if (!waitingShown) {
|
||||
waitingShown = true;
|
||||
setHistory(prev => [...prev, {
|
||||
command: options.commandLabel,
|
||||
output: options.waitingOutput,
|
||||
}]);
|
||||
}
|
||||
if (attempt === GATE_LAUNCH_RETRY_ATTEMPTS - 1) {
|
||||
if (gateLaunchAttemptRef.current !== launchId) {
|
||||
return;
|
||||
}
|
||||
setHistory(prev => [...prev, {
|
||||
command: options.commandLabel,
|
||||
output: (
|
||||
<span className="text-red-400">
|
||||
ERR: The obfuscated lane is taking too long to come online. It is still warming up in the background.
|
||||
</span>
|
||||
),
|
||||
}]);
|
||||
return;
|
||||
}
|
||||
await sleep(GATE_LAUNCH_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommand = (cmd: string) => {
|
||||
const trimmedCmd = cmd.trim().toLowerCase();
|
||||
let output: React.ReactNode = '';
|
||||
@@ -293,18 +382,24 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
setHistory(prev => [...prev, { command: cmd, output }]);
|
||||
setPendingGate(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await enterWormholeGate(gateTarget, true);
|
||||
setActiveGateMode('anonymous');
|
||||
setActiveGate(gateTarget);
|
||||
setCurrentView('gate');
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : 'anonymous_gate_enter_failed';
|
||||
setHistory(prev => [...prev, {
|
||||
command: `gate ${gateTarget}`,
|
||||
output: <span className="text-red-400">ERR: {detail}</span>,
|
||||
}]);
|
||||
}
|
||||
await openGateWhenReady(
|
||||
gateTarget,
|
||||
async () => {
|
||||
await enterWormholeGate(gateTarget, true);
|
||||
setActiveGateMode('anonymous');
|
||||
setActiveGate(gateTarget);
|
||||
setCurrentView('gate');
|
||||
},
|
||||
{
|
||||
commandLabel: `gate ${gateTarget}`,
|
||||
waitingOutput: (
|
||||
<span className="text-cyan-400">
|
||||
Warming the obfuscated lane for g/{gateTarget}. The room will open automatically as soon as it is ready.
|
||||
</span>
|
||||
),
|
||||
failurePrefix: 'anonymous_gate_enter_failed',
|
||||
},
|
||||
);
|
||||
})();
|
||||
return;
|
||||
}
|
||||
@@ -312,30 +407,36 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
setHistory(prev => [...prev, { command: cmd, output }]);
|
||||
setPendingGate(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const personas = await listWormholeGatePersonas(gateTarget);
|
||||
const existing = Array.isArray(personas?.personas)
|
||||
? personas.personas.find(
|
||||
(candidate) =>
|
||||
String(candidate?.label || '').trim().toLowerCase() === chosenPersona.toLowerCase(),
|
||||
)
|
||||
: null;
|
||||
const result = existing?.persona_id
|
||||
? await activateWormholeGatePersona(gateTarget, existing.persona_id)
|
||||
: await createWormholeGatePersona(gateTarget, chosenPersona);
|
||||
if (!result?.ok) {
|
||||
throw new Error(result?.detail || 'gate_face_create_failed');
|
||||
}
|
||||
setActiveGateMode('persona');
|
||||
setActiveGate(gateTarget);
|
||||
setCurrentView('gate');
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : 'gate_face_create_failed';
|
||||
setHistory(prev => [...prev, {
|
||||
command: `join ${gateTarget}`,
|
||||
output: <span className="text-red-400">ERR: {detail}</span>,
|
||||
}]);
|
||||
}
|
||||
await openGateWhenReady(
|
||||
gateTarget,
|
||||
async () => {
|
||||
const personas = await listWormholeGatePersonas(gateTarget);
|
||||
const existing = Array.isArray(personas?.personas)
|
||||
? personas.personas.find(
|
||||
(candidate) =>
|
||||
String(candidate?.label || '').trim().toLowerCase() === chosenPersona.toLowerCase(),
|
||||
)
|
||||
: null;
|
||||
const result = existing?.persona_id
|
||||
? await activateWormholeGatePersona(gateTarget, existing.persona_id)
|
||||
: await createWormholeGatePersona(gateTarget, chosenPersona);
|
||||
if (!result?.ok) {
|
||||
throw new Error(result?.detail || 'gate_face_create_failed');
|
||||
}
|
||||
setActiveGateMode('persona');
|
||||
setActiveGate(gateTarget);
|
||||
setCurrentView('gate');
|
||||
},
|
||||
{
|
||||
commandLabel: `join ${gateTarget}`,
|
||||
waitingOutput: (
|
||||
<span className="text-cyan-400">
|
||||
Warming the obfuscated lane for g/{gateTarget}. Your gate face will open automatically when the room is ready.
|
||||
</span>
|
||||
),
|
||||
failurePrefix: 'gate_face_create_failed',
|
||||
},
|
||||
);
|
||||
})();
|
||||
return;
|
||||
}
|
||||
@@ -351,7 +452,12 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
<li><span className="text-gray-300 font-bold">radio</span> - Open SIGINT / radio surfaces</li>
|
||||
<li><span className="text-gray-300 font-bold">messages</span> - Open Secure Comms</li>
|
||||
<li><span className="text-gray-300 font-bold">profile</span> - View sovereign identity & ledger</li>
|
||||
<li><span className="text-gray-300 font-bold">ballot</span> - View democratic proposals</li>
|
||||
<li><span className="text-gray-300 font-bold">ballot / petitions / governance</span> - File / sign / vote on petitions (DSL executor)</li>
|
||||
<li><span className="text-gray-300 font-bold">upgrades</span> - Upgrade-hash governance + Heavy-Node readiness</li>
|
||||
<li><span className="text-gray-300 font-bold">resolution [market_id]</span> - Evidence + dispute view</li>
|
||||
<li><span className="text-gray-300 font-bold">shutdown [gate_id]</span> - Gate suspend / shutdown / appeal lifecycle</li>
|
||||
<li><span className="text-gray-300 font-bold">bootstrap</span> - Bootstrap-mode resolution + ramp milestones</li>
|
||||
<li><span className="text-gray-300 font-bold">fkeys / function-keys</span> - Anonymous citizenship proof design</li>
|
||||
<li><span className="text-gray-300 font-bold">gigs</span> - View network bounties & jobs</li>
|
||||
<li><span className="text-gray-300 font-bold">markets</span> - View prediction markets</li>
|
||||
<li><span className="text-gray-300 font-bold">exchange</span> - Decentralized crypto exchange</li>
|
||||
@@ -387,6 +493,9 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
} else {
|
||||
output = <span className="text-red-400">ERR: Gate '{target}' not found or access denied.</span>;
|
||||
}
|
||||
} else if (trimmedCmd === 'ai' || trimmedCmd === 'copilot' || trimmedCmd === 'openclaw') {
|
||||
handleNavigate('ai');
|
||||
return;
|
||||
} else if (trimmedCmd === 'markets') {
|
||||
handleNavigate('market');
|
||||
return;
|
||||
@@ -396,9 +505,35 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
} else if (trimmedCmd === 'profile') {
|
||||
handleNavigate('profile');
|
||||
return;
|
||||
} else if (trimmedCmd === 'ballot') {
|
||||
setComingSoonModule('BALLOT');
|
||||
} else if (trimmedCmd === 'ballot' || trimmedCmd === 'petitions' || trimmedCmd === 'governance') {
|
||||
setCurrentView('petitions');
|
||||
return;
|
||||
} else if (trimmedCmd === 'upgrades' || trimmedCmd === 'upgrade') {
|
||||
setCurrentView('upgrades');
|
||||
return;
|
||||
} else if (trimmedCmd === 'bootstrap') {
|
||||
setBootstrapMarketId(null);
|
||||
setCurrentView('bootstrap');
|
||||
return;
|
||||
} else if (trimmedCmd === 'function-keys' || trimmedCmd === 'fkeys') {
|
||||
setCurrentView('function-keys');
|
||||
return;
|
||||
} else if (trimmedCmd.startsWith('resolution ')) {
|
||||
const mid = trimmedCmd.slice('resolution '.length).trim();
|
||||
if (mid) {
|
||||
setResolutionMarketId(mid);
|
||||
setCurrentView('resolution');
|
||||
return;
|
||||
}
|
||||
output = <span className="text-red-400">Usage: resolution <market_id></span>;
|
||||
} else if (trimmedCmd.startsWith('shutdown ')) {
|
||||
const gid = trimmedCmd.slice('shutdown '.length).trim();
|
||||
if (gid) {
|
||||
setShutdownGateId(gid);
|
||||
setCurrentView('gate-shutdown');
|
||||
return;
|
||||
}
|
||||
output = <span className="text-red-400">Usage: shutdown <gate_id></span>;
|
||||
} else if (trimmedCmd === 'work' || trimmedCmd === 'gigs') {
|
||||
setComingSoonModule('GIGS');
|
||||
return;
|
||||
@@ -495,7 +630,11 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
{SECTIONS.map((section) => (
|
||||
<button
|
||||
key={section.name}
|
||||
onClick={() => handleCommand(section.name === 'PROFILE' ? 'profile' : section.name.toLowerCase())}
|
||||
onClick={() => handleCommand(
|
||||
section.name === 'PROFILE' ? 'profile' :
|
||||
section.name === 'F-KEYS' ? 'fkeys' :
|
||||
section.name.toLowerCase()
|
||||
)}
|
||||
className="flex items-center px-2 py-1 bg-cyan-900/10 border border-cyan-900/50 text-cyan-500 hover:bg-cyan-900/30 hover:text-cyan-400 hover:border-cyan-500/50 transition-all text-sm md:text-xs uppercase tracking-widest whitespace-nowrap"
|
||||
>
|
||||
{section.icon}
|
||||
@@ -593,6 +732,10 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
onBack={() => handleNavigate('terminal')}
|
||||
onNavigateGate={(gate) => handleNavigate('gate', gate)}
|
||||
onOpenLiveGate={onOpenLiveGate}
|
||||
onOpenShutdownPetition={(gate) => {
|
||||
setShutdownGateId(gate);
|
||||
setCurrentView('gate-shutdown');
|
||||
}}
|
||||
availableGates={GATES}
|
||||
/>
|
||||
)}
|
||||
@@ -612,7 +755,44 @@ export default function InfonetShell({ isOpen, onClose, onOpenLiveGate }: Infone
|
||||
)}
|
||||
|
||||
{currentView === 'messages' && (
|
||||
<MessagesView onBack={() => handleNavigate('terminal')} />
|
||||
<MessagesView onBack={() => handleNavigate('terminal')} onOpenDeadDrop={onOpenDeadDrop} />
|
||||
)}
|
||||
|
||||
{currentView === 'ai' && (
|
||||
<AIQueryView onBack={() => handleNavigate('terminal')} />
|
||||
)}
|
||||
|
||||
{currentView === 'petitions' && (
|
||||
<PetitionsView onBack={() => setCurrentView('terminal')} />
|
||||
)}
|
||||
|
||||
{currentView === 'upgrades' && (
|
||||
<UpgradeView onBack={() => setCurrentView('terminal')} />
|
||||
)}
|
||||
|
||||
{currentView === 'resolution' && resolutionMarketId && (
|
||||
<ResolutionView
|
||||
marketId={resolutionMarketId}
|
||||
onBack={() => setCurrentView('terminal')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'gate-shutdown' && shutdownGateId && (
|
||||
<GateShutdownView
|
||||
gateId={shutdownGateId}
|
||||
onBack={() => setCurrentView('terminal')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'bootstrap' && (
|
||||
<BootstrapView
|
||||
marketId={bootstrapMarketId ?? undefined}
|
||||
onBack={() => setCurrentView('terminal')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'function-keys' && (
|
||||
<FunctionKeyView onBack={() => setCurrentView('terminal')} />
|
||||
)}
|
||||
|
||||
{/* Coming Soon Popup */}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronLeft, Search, Activity, Shield, Crosshair, DollarSign, Newspaper } from 'lucide-react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { ChevronLeft, Search, Activity, Shield, Crosshair, DollarSign, Newspaper, ExternalLink, Loader } from 'lucide-react';
|
||||
import { useDataKeys } from '@/hooks/useDataStore';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import type { DashboardData, StockTicker } from '@/types/dashboard';
|
||||
|
||||
function formatVolume(vol: number): string {
|
||||
function formatVolume(vol: number | null | undefined): string {
|
||||
if (!vol || vol <= 0) return '';
|
||||
if (vol >= 1_000_000) return `$${(vol / 1_000_000).toFixed(1)}M`;
|
||||
if (vol >= 1_000) return `$${(vol / 1_000).toFixed(0)}K`;
|
||||
@@ -32,33 +33,185 @@ const CATEGORY_CONFIG: Record<string, { color: string; icon: typeof Shield }> =
|
||||
CONFLICT: { color: 'text-red-400', icon: Crosshair },
|
||||
FINANCE: { color: 'text-emerald-400', icon: DollarSign },
|
||||
CRYPTO: { color: 'text-amber-400', icon: DollarSign },
|
||||
SPORTS: { color: 'text-orange-400', icon: Activity },
|
||||
NEWS: { color: 'text-cyan-400', icon: Newspaper },
|
||||
};
|
||||
|
||||
type Category = 'ALL' | 'POLITICS' | 'CONFLICT' | 'FINANCE' | 'CRYPTO' | 'NEWS';
|
||||
type Category = 'ALL' | 'POLITICS' | 'CONFLICT' | 'FINANCE' | 'CRYPTO' | 'SPORTS' | 'NEWS';
|
||||
|
||||
interface MarketViewProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type MarketSource = {
|
||||
name: string;
|
||||
pct: number;
|
||||
};
|
||||
|
||||
type MarketOutcome = {
|
||||
name: string;
|
||||
pct: number;
|
||||
};
|
||||
|
||||
type PredictionMarket = {
|
||||
title: string;
|
||||
category?: Category | string;
|
||||
consensus_pct?: number | null;
|
||||
polymarket_pct?: number | null;
|
||||
kalshi_pct?: number | null;
|
||||
volume?: number | null;
|
||||
volume_24h?: number | null;
|
||||
end_date?: string | null;
|
||||
description?: string | null;
|
||||
sources?: MarketSource[];
|
||||
slug?: string;
|
||||
kalshi_ticker?: string;
|
||||
outcomes?: MarketOutcome[];
|
||||
delta_pct?: number | null;
|
||||
consensus?: {
|
||||
total_picks: number;
|
||||
total_staked: number;
|
||||
};
|
||||
};
|
||||
|
||||
type DataSlice = Pick<DashboardData, 'trending_markets' | 'stocks'>;
|
||||
const DATA_KEYS = ['trending_markets', 'stocks'] as const;
|
||||
|
||||
export default function MarketView({ onBack }: MarketViewProps) {
|
||||
const [category, setCategory] = useState<Category>('ALL');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<PredictionMarket[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [allMarkets, setAllMarkets] = useState<PredictionMarket[]>([]);
|
||||
const [marketTotals, setMarketTotals] = useState<Record<string, number>>({});
|
||||
const [marketHasMore, setMarketHasMore] = useState<Record<string, boolean>>({});
|
||||
const [searchHasMore, setSearchHasMore] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [allBrowseOffset, setAllBrowseOffset] = useState(0);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const data = useDataKeys(DATA_KEYS) as DataSlice;
|
||||
const markets = data?.trending_markets || [];
|
||||
const stocks = data?.stocks;
|
||||
|
||||
const filteredMarkets = markets.filter(m => {
|
||||
const appendUniqueMarkets = useCallback((existing: PredictionMarket[], incoming: PredictionMarket[]) => {
|
||||
const seen = new Set(existing.map((m) => String(m.slug || m.kalshi_ticker || m.title).toLowerCase()));
|
||||
const next = [...existing];
|
||||
for (const market of incoming) {
|
||||
const key = String(market.slug || market.kalshi_ticker || market.title).toLowerCase();
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
next.push(market);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
// Fetch all markets from the oracle endpoint on mount
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/mesh/oracle/markets`);
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
const cats = d.categories || {};
|
||||
const all: PredictionMarket[] = [];
|
||||
for (const cat of Object.values(cats) as PredictionMarket[][]) {
|
||||
all.push(...cat);
|
||||
}
|
||||
if (mounted) {
|
||||
setAllMarkets(appendUniqueMarkets([], all));
|
||||
const totals = d.cat_totals || {};
|
||||
setMarketTotals({ ...totals, ALL: d.total_count || all.length });
|
||||
const more: Record<string, boolean> = {};
|
||||
for (const [cat, count] of Object.entries(totals)) {
|
||||
const loaded = Array.isArray(cats[cat]) ? cats[cat].length : 0;
|
||||
more[cat] = Number(count) > loaded;
|
||||
}
|
||||
more.ALL = Number(d.total_count || 0) > all.length;
|
||||
setMarketHasMore(more);
|
||||
}
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, [appendUniqueMarkets]);
|
||||
|
||||
// API search — hits Polymarket + Kalshi directly
|
||||
const searchMarkets = useCallback(async (query: string, offset = 0) => {
|
||||
if (query.length < 2) {
|
||||
setSearchResults([]);
|
||||
setSearchHasMore(false);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/mesh/oracle/search?q=${encodeURIComponent(query)}&limit=50&offset=${offset}`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
const results = d.results || [];
|
||||
setSearchResults((prev) => (offset > 0 ? appendUniqueMarkets(prev, results) : results));
|
||||
setSearchHasMore(Boolean(d.has_more));
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
setIsSearching(false);
|
||||
}, [appendUniqueMarkets]);
|
||||
|
||||
const loadMoreMarkets = useCallback(async () => {
|
||||
if (loadingMore) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
if (searchInput.length >= 2) {
|
||||
await searchMarkets(searchInput, searchResults.length);
|
||||
return;
|
||||
}
|
||||
const loadedForCategory =
|
||||
category === 'ALL'
|
||||
? allBrowseOffset
|
||||
: allMarkets.filter((m) => m.category === category).length;
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/mesh/oracle/markets/more?category=${encodeURIComponent(category)}&offset=${loadedForCategory}&limit=50`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
const markets = d.markets || [];
|
||||
setAllMarkets((prev) => appendUniqueMarkets(prev, markets));
|
||||
if (category === 'ALL') {
|
||||
setAllBrowseOffset((prev) => prev + markets.length);
|
||||
}
|
||||
setMarketHasMore((prev) => ({ ...prev, [category]: Boolean(d.has_more) }));
|
||||
setMarketTotals((prev) => ({ ...prev, [category]: d.total ?? prev[category] ?? loadedForCategory }));
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [allBrowseOffset, allMarkets, appendUniqueMarkets, category, loadingMore, searchInput, searchMarkets, searchResults.length]);
|
||||
|
||||
const handleSearchInput = useCallback(
|
||||
(value: string) => {
|
||||
setSearchInput(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => searchMarkets(value), 400);
|
||||
},
|
||||
[searchMarkets],
|
||||
);
|
||||
|
||||
// Use search results when searching, otherwise show all markets
|
||||
const displayMarkets = searchInput.length >= 2 ? searchResults : allMarkets;
|
||||
const filteredMarkets = displayMarkets.filter(m => {
|
||||
const matchesCat = category === 'ALL' || m.category === category;
|
||||
const matchesSearch = !searchInput || m.title.toLowerCase().includes(searchInput.toLowerCase());
|
||||
return matchesCat && matchesSearch;
|
||||
return matchesCat;
|
||||
});
|
||||
|
||||
const CATEGORIES: Category[] = ['ALL', 'POLITICS', 'CONFLICT', 'FINANCE', 'CRYPTO', 'NEWS'];
|
||||
const CATEGORIES: Category[] = ['ALL', 'POLITICS', 'CONFLICT', 'FINANCE', 'CRYPTO', 'SPORTS', 'NEWS'];
|
||||
const currentTotal = searchInput.length >= 2
|
||||
? null
|
||||
: marketTotals[category] ?? filteredMarkets.length;
|
||||
const canLoadMore = searchInput.length >= 2 ? searchHasMore : Boolean(marketHasMore[category]);
|
||||
|
||||
// Build ticker from real stocks data
|
||||
const tickerItems: string[] = [];
|
||||
@@ -87,7 +240,10 @@ export default function MarketView({ onBack }: MarketViewProps) {
|
||||
<Activity className="mr-2 text-cyan-400 animate-pulse" />
|
||||
PREDICTION MARKETS
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Live Polymarket + Kalshi feeds. {markets.length} active markets tracked.</p>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
Live Polymarket + Kalshi feeds. Search anything — all markets from both platforms.
|
||||
{' '}{allMarkets.length > 0 && `${allMarkets.length} cached markets.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
@@ -107,32 +263,45 @@ export default function MarketView({ onBack }: MarketViewProps) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 font-mono">{filteredMarkets.length} RESULTS</span>
|
||||
<span className="text-sm text-gray-500 font-mono">
|
||||
{filteredMarkets.length}{currentTotal != null && currentTotal > filteredMarkets.length ? ` / ${currentTotal}` : ''} RESULTS
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-4 shrink-0">
|
||||
<div className="flex items-center border border-gray-800 bg-[#0a0a0a] p-2">
|
||||
<Search size={14} className="text-gray-600 mr-2" />
|
||||
{isSearching ? (
|
||||
<Loader size={14} className="text-cyan-500 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Search size={14} className="text-gray-600 mr-2" />
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search prediction markets..."
|
||||
onChange={(e) => handleSearchInput(e.target.value)}
|
||||
placeholder="Search ALL Polymarket + Kalshi markets (e.g. avalanche, bitcoin, trump, war)..."
|
||||
className="bg-transparent border-none outline-none text-white w-full text-sm placeholder-gray-700"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
{searchInput.length >= 2 && (
|
||||
<div className="text-xs font-mono text-gray-600 mt-1 px-1">
|
||||
{isSearching
|
||||
? 'SEARCHING POLYMARKET + KALSHI APIs...'
|
||||
: `${searchResults.length} RESULTS FROM POLYMARKET + KALSHI`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Markets List */}
|
||||
<div className="flex-1 overflow-y-auto pr-2 space-y-3 pb-4">
|
||||
{filteredMarkets.length > 0 ? filteredMarkets.map((market, i) => {
|
||||
const pct = market.consensus_pct ?? market.polymarket_pct ?? market.kalshi_pct ?? 0;
|
||||
const catConfig = CATEGORY_CONFIG[market.category] || { color: 'text-gray-400' };
|
||||
const categoryLabel = market.category ?? 'UNCATEGORIZED';
|
||||
const catConfig = CATEGORY_CONFIG[categoryLabel] || { color: 'text-gray-400' };
|
||||
const vol = formatVolume(market.volume);
|
||||
const vol24 = formatVolume(market.volume_24h);
|
||||
// Runtime-optional fields the backend may send but aren't in the strict TS type
|
||||
const raw = market as Record<string, unknown>;
|
||||
const endDate = formatEndDate(typeof raw.end_date === 'string' ? raw.end_date : null);
|
||||
const outcomes = market.outcomes && market.outcomes.length > 0 ? market.outcomes : null;
|
||||
@@ -145,7 +314,7 @@ export default function MarketView({ onBack }: MarketViewProps) {
|
||||
<div className="flex-1">
|
||||
<div className="text-gray-300 font-bold text-sm md:text-base leading-snug">{market.title}</div>
|
||||
<div className="flex items-center gap-2 mt-1.5 text-sm font-mono">
|
||||
<span className={`${catConfig.color} uppercase tracking-widest`}>{market.category}</span>
|
||||
<span className={`${catConfig.color} uppercase tracking-widest`}>{categoryLabel}</span>
|
||||
{vol && <span className="text-gray-500">VOL: {vol}</span>}
|
||||
{vol24 && <span className="text-gray-500">24H: {vol24}</span>}
|
||||
{endDate && <span className="text-gray-500">CLOSES: {endDate}</span>}
|
||||
@@ -187,7 +356,7 @@ export default function MarketView({ onBack }: MarketViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source badges */}
|
||||
{/* Source badges + external links */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{market.sources?.map((s, si) => (
|
||||
@@ -205,6 +374,23 @@ export default function MarketView({ onBack }: MarketViewProps) {
|
||||
{consensus.total_staked > 0 ? ` · ${consensus.total_staked.toFixed(1)} REP` : ''}
|
||||
</span>
|
||||
)}
|
||||
{/* External links */}
|
||||
{market.slug && (
|
||||
<button
|
||||
onClick={() => window.open(`https://polymarket.com/event/${market.slug}`, '_blank', 'noopener,noreferrer')}
|
||||
className="flex items-center gap-1 text-[11px] font-mono px-1.5 py-0.5 border border-purple-500/30 bg-purple-500/10 text-purple-400 hover:bg-purple-500/20 cursor-pointer"
|
||||
>
|
||||
<ExternalLink size={9} /> POLY
|
||||
</button>
|
||||
)}
|
||||
{market.kalshi_ticker && (
|
||||
<button
|
||||
onClick={() => window.open(`https://kalshi.com/markets/${market.kalshi_ticker}`, '_blank', 'noopener,noreferrer')}
|
||||
className="flex items-center gap-1 text-[11px] font-mono px-1.5 py-0.5 border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 cursor-pointer"
|
||||
>
|
||||
<ExternalLink size={9} /> KALSHI
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delta indicator */}
|
||||
@@ -233,11 +419,31 @@ export default function MarketView({ onBack }: MarketViewProps) {
|
||||
);
|
||||
}) : (
|
||||
<div className="text-center text-gray-600 py-8">
|
||||
<p className="text-sm italic">No markets found{searchInput ? ` for "${searchInput}"` : ''}.</p>
|
||||
{isSearching ? (
|
||||
<p className="text-sm">Searching Polymarket + Kalshi...</p>
|
||||
) : (
|
||||
<p className="text-sm italic">No markets found{searchInput ? ` for "${searchInput}"` : ''}.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canLoadMore && (
|
||||
<div className="shrink-0 flex justify-center pb-3">
|
||||
<button
|
||||
onClick={() => void loadMoreMarkets()}
|
||||
disabled={loadingMore || isSearching}
|
||||
className="px-4 py-2 text-xs uppercase tracking-widest border border-cyan-900/50 bg-cyan-900/10 text-cyan-400 hover:border-cyan-500/50 hover:bg-cyan-900/30 disabled:opacity-50"
|
||||
>
|
||||
{loadingMore || isSearching
|
||||
? 'LOADING MORE...'
|
||||
: searchInput.length >= 2
|
||||
? 'MORE SEARCH RESULTS'
|
||||
: `MORE ${category} MARKETS`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ticker */}
|
||||
{tickerItems.length > 0 && (
|
||||
<div className="shrink-0 border-t border-gray-800 bg-gray-900/30 overflow-hidden py-2 mt-2">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,618 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, FileText, Vote, Shield, AlertCircle, CheckCircle2, Loader } from 'lucide-react';
|
||||
import {
|
||||
buildChallengeFilePayload,
|
||||
buildPetitionFilePayload,
|
||||
buildPetitionSignPayload,
|
||||
buildPetitionVotePayload,
|
||||
fetchPetitions,
|
||||
freshLocalId,
|
||||
previewPetitionPayload,
|
||||
signAndAppend,
|
||||
type PetitionPayload,
|
||||
type PetitionState,
|
||||
} from '@/mesh/infonetEconomyClient';
|
||||
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
|
||||
|
||||
interface PetitionsViewProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<string, { color: string; label: string; icon: typeof Vote }> = {
|
||||
signatures: { color: 'text-cyan-400', label: 'COLLECTING SIGNATURES', icon: FileText },
|
||||
voting: { color: 'text-blue-400', label: 'VOTING', icon: Vote },
|
||||
challenge: { color: 'text-amber-400', label: 'CHALLENGE WINDOW', icon: Shield },
|
||||
passed: { color: 'text-green-400', label: 'PASSED', icon: CheckCircle2 },
|
||||
executed: { color: 'text-green-500', label: 'EXECUTED', icon: CheckCircle2 },
|
||||
failed_signatures: { color: 'text-red-400', label: 'FAILED — SIGNATURES', icon: AlertCircle },
|
||||
failed_vote: { color: 'text-red-400', label: 'FAILED — VOTE', icon: AlertCircle },
|
||||
voided_challenge: { color: 'text-red-500', label: 'VOIDED BY CHALLENGE', icon: AlertCircle },
|
||||
not_found: { color: 'text-gray-500', label: 'NOT FOUND', icon: AlertCircle },
|
||||
};
|
||||
|
||||
function formatRelative(ts: number, now: number): string {
|
||||
if (!ts) return '—';
|
||||
const delta = ts - now;
|
||||
const abs = Math.abs(delta);
|
||||
const days = Math.floor(abs / 86400);
|
||||
const hours = Math.floor((abs % 86400) / 3600);
|
||||
if (delta > 0) {
|
||||
if (days > 0) return `in ${days}d ${hours}h`;
|
||||
if (hours > 0) return `in ${hours}h`;
|
||||
return `in ${Math.floor(abs / 60)}m`;
|
||||
} else {
|
||||
if (days > 0) return `${days}d ago`;
|
||||
if (hours > 0) return `${hours}h ago`;
|
||||
return `${Math.floor(abs / 60)}m ago`;
|
||||
}
|
||||
}
|
||||
|
||||
function PayloadSummary({ payload }: { payload: PetitionPayload | Record<string, unknown> }) {
|
||||
const t = (payload as { type?: string }).type;
|
||||
if (t === 'UPDATE_PARAM') {
|
||||
const p = payload as Extract<PetitionPayload, { type: 'UPDATE_PARAM' }>;
|
||||
return (
|
||||
<span className="text-gray-300">
|
||||
Set <span className="text-cyan-400 font-bold">{p.key}</span> = {' '}
|
||||
<span className="text-white font-bold">{String(p.value)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (t === 'BATCH_UPDATE_PARAMS') {
|
||||
const p = payload as Extract<PetitionPayload, { type: 'BATCH_UPDATE_PARAMS' }>;
|
||||
return (
|
||||
<span className="text-gray-300">
|
||||
Update {p.updates?.length ?? 0} parameters atomically
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (t === 'ENABLE_FEATURE') {
|
||||
const p = payload as Extract<PetitionPayload, { type: 'ENABLE_FEATURE' }>;
|
||||
return <span className="text-gray-300">Enable feature <span className="text-green-400">{p.feature}</span></span>;
|
||||
}
|
||||
if (t === 'DISABLE_FEATURE') {
|
||||
const p = payload as Extract<PetitionPayload, { type: 'DISABLE_FEATURE' }>;
|
||||
return <span className="text-gray-300">Disable feature <span className="text-red-400">{p.feature}</span></span>;
|
||||
}
|
||||
return <span className="text-gray-500">Unknown payload type</span>;
|
||||
}
|
||||
|
||||
function PetitionRow({
|
||||
petition,
|
||||
now,
|
||||
onAction,
|
||||
}: {
|
||||
petition: PetitionState;
|
||||
now: number;
|
||||
onAction: () => void;
|
||||
}) {
|
||||
const style = STATUS_STYLE[petition.status] ?? STATUS_STYLE.not_found;
|
||||
const Icon = style.icon;
|
||||
const sigPct = petition.signature_threshold_at_filing > 0
|
||||
? (petition.signature_governance_weight / petition.signature_threshold_at_filing) * 100
|
||||
: 0;
|
||||
const totalVotes = petition.votes_for_weight + petition.votes_against_weight;
|
||||
const yesPct = totalVotes > 0
|
||||
? (petition.votes_for_weight / totalVotes) * 100
|
||||
: 0;
|
||||
const { state, result, submit } = useSignAndAppend();
|
||||
const busy = state === 'submitting';
|
||||
|
||||
const sign = useCallback(async () => {
|
||||
const built = buildPetitionSignPayload(petition.petition_id);
|
||||
const res = await submit(built.event_type, built.payload);
|
||||
if (res.ok) onAction();
|
||||
}, [petition.petition_id, submit, onAction]);
|
||||
|
||||
const voteFor = useCallback(async () => {
|
||||
const built = buildPetitionVotePayload(petition.petition_id, 'for');
|
||||
const res = await submit(built.event_type, built.payload);
|
||||
if (res.ok) onAction();
|
||||
}, [petition.petition_id, submit, onAction]);
|
||||
|
||||
const voteAgainst = useCallback(async () => {
|
||||
const built = buildPetitionVotePayload(petition.petition_id, 'against');
|
||||
const res = await submit(built.event_type, built.payload);
|
||||
if (res.ok) onAction();
|
||||
}, [petition.petition_id, submit, onAction]);
|
||||
|
||||
const challenge = useCallback(async () => {
|
||||
const reason = window.prompt(
|
||||
'Constitutional challenge — describe why this petition violates the constitution:',
|
||||
);
|
||||
if (!reason || !reason.trim()) return;
|
||||
const built = buildChallengeFilePayload(petition.petition_id, reason.trim());
|
||||
const res = await submit(built.event_type, built.payload);
|
||||
if (res.ok) onAction();
|
||||
}, [petition.petition_id, submit, onAction]);
|
||||
|
||||
return (
|
||||
<div className="border border-gray-800 bg-black/40 p-3 hover:bg-black/60 transition-colors">
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icon size={14} className={style.color} />
|
||||
<span className={`text-xs font-bold uppercase tracking-wider ${style.color}`}>
|
||||
{style.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 font-mono truncate">
|
||||
{petition.petition_id.slice(0, 16)}…
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm mb-2">
|
||||
<PayloadSummary payload={petition.petition_payload} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||
<div>
|
||||
<div className="text-gray-500">Filer</div>
|
||||
<div className="text-gray-300 font-mono truncate" title={petition.filer_id}>
|
||||
{petition.filer_id.slice(0, 12)}…
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Filed</div>
|
||||
<div className="text-gray-300">{formatRelative(petition.filed_at, now)}</div>
|
||||
</div>
|
||||
{petition.status === 'signatures' && (
|
||||
<div className="col-span-2">
|
||||
<div className="text-gray-500">
|
||||
Signatures: {petition.signature_governance_weight.toFixed(1)} / {petition.signature_threshold_at_filing.toFixed(1)}
|
||||
</div>
|
||||
<div className="h-1 bg-gray-800 mt-1 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-cyan-500 transition-all"
|
||||
style={{ width: `${Math.min(100, sigPct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(petition.status === 'voting' || petition.status === 'challenge'
|
||||
|| petition.status === 'passed' || petition.status === 'executed') && (
|
||||
<div className="col-span-2">
|
||||
<div className="text-gray-500">
|
||||
Vote: {petition.votes_for_weight.toFixed(1)} for / {petition.votes_against_weight.toFixed(1)} against
|
||||
</div>
|
||||
<div className="h-1 bg-gray-800 mt-1 overflow-hidden flex">
|
||||
<div
|
||||
className="h-full bg-green-500 transition-all"
|
||||
style={{ width: `${yesPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-red-500 transition-all"
|
||||
style={{ width: `${100 - yesPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{petition.voting_deadline && petition.status === 'voting' && (
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
Voting closes {formatRelative(petition.voting_deadline, now)}
|
||||
</div>
|
||||
)}
|
||||
{petition.challenge_window_until && petition.status === 'challenge' && (
|
||||
<div className="text-xs text-amber-400 mt-2">
|
||||
Challenge window closes {formatRelative(petition.challenge_window_until, now)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{petition.status === 'signatures' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={sign}
|
||||
disabled={busy}
|
||||
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-cyan-700/50 bg-cyan-900/20 text-cyan-400 hover:bg-cyan-900/40 disabled:opacity-30"
|
||||
>
|
||||
{busy ? 'Signing…' : 'Sign'}
|
||||
</button>
|
||||
)}
|
||||
{petition.status === 'voting' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={voteFor}
|
||||
disabled={busy}
|
||||
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-green-700/50 bg-green-900/20 text-green-400 hover:bg-green-900/40 disabled:opacity-30"
|
||||
>
|
||||
{busy ? '…' : 'Vote FOR'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={voteAgainst}
|
||||
disabled={busy}
|
||||
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
|
||||
>
|
||||
{busy ? '…' : 'Vote AGAINST'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{petition.status === 'challenge' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={challenge}
|
||||
disabled={busy}
|
||||
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-amber-700/50 bg-amber-900/20 text-amber-400 hover:bg-amber-900/40 disabled:opacity-30"
|
||||
title="File a constitutional challenge against this passed petition"
|
||||
>
|
||||
{busy ? '…' : 'Challenge'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result && !result.ok && (
|
||||
<div className="text-xs text-red-400 font-mono mt-2 break-all">
|
||||
<AlertCircle size={10} className="inline mr-1" />
|
||||
{result.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilePetitionForm({ onFiled }: { onFiled?: () => void }) {
|
||||
const [paramKey, setParamKey] = useState('');
|
||||
const [paramValue, setParamValue] = useState('');
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [filing, setFiling] = useState(false);
|
||||
const [previewResult, setPreviewResult] = useState<
|
||||
{ ok: true; changedKeys: string[]; newValues: Record<string, unknown> } |
|
||||
{ ok: false; reason: string } | null
|
||||
>(null);
|
||||
const [fileResult, setFileResult] = useState<
|
||||
{ ok: true; eventId: string } |
|
||||
{ ok: false; reason: string } | null
|
||||
>(null);
|
||||
|
||||
const handlePreview = useCallback(async () => {
|
||||
if (!paramKey.trim()) return;
|
||||
setPreviewing(true);
|
||||
setPreviewResult(null);
|
||||
try {
|
||||
// Try numeric coercion; fall back to string. Backend validator
|
||||
// rejects type mismatches with a diagnostic — surfaces directly.
|
||||
let value: unknown = paramValue;
|
||||
const numeric = Number(paramValue);
|
||||
if (paramValue.trim() !== '' && !Number.isNaN(numeric)) {
|
||||
value = numeric;
|
||||
} else if (paramValue.trim().toLowerCase() === 'true') {
|
||||
value = true;
|
||||
} else if (paramValue.trim().toLowerCase() === 'false') {
|
||||
value = false;
|
||||
}
|
||||
const payload: PetitionPayload = {
|
||||
type: 'UPDATE_PARAM',
|
||||
key: paramKey.trim(),
|
||||
value,
|
||||
};
|
||||
const res = await previewPetitionPayload(payload);
|
||||
if (res.ok) {
|
||||
setPreviewResult({
|
||||
ok: true,
|
||||
changedKeys: res.changed_keys ?? [],
|
||||
newValues: res.new_values ?? {},
|
||||
});
|
||||
} else {
|
||||
setPreviewResult({ ok: false, reason: res.reason ?? 'unknown_error' });
|
||||
}
|
||||
} catch (err) {
|
||||
setPreviewResult({
|
||||
ok: false,
|
||||
reason: err instanceof Error ? err.message : 'network_error',
|
||||
});
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
}, [paramKey, paramValue]);
|
||||
|
||||
const buildPayload = useCallback((): PetitionPayload | null => {
|
||||
if (!paramKey.trim()) return null;
|
||||
let value: unknown = paramValue;
|
||||
const numeric = Number(paramValue);
|
||||
if (paramValue.trim() !== '' && !Number.isNaN(numeric)) {
|
||||
value = numeric;
|
||||
} else if (paramValue.trim().toLowerCase() === 'true') {
|
||||
value = true;
|
||||
} else if (paramValue.trim().toLowerCase() === 'false') {
|
||||
value = false;
|
||||
}
|
||||
return { type: 'UPDATE_PARAM', key: paramKey.trim(), value };
|
||||
}, [paramKey, paramValue]);
|
||||
|
||||
const handleFile = useCallback(async () => {
|
||||
const inner = buildPayload();
|
||||
if (!inner) return;
|
||||
setFiling(true);
|
||||
setFileResult(null);
|
||||
try {
|
||||
// Generate a fresh petition_id deterministically from the payload
|
||||
// + timestamp so refile attempts produce distinct IDs.
|
||||
const petitionId = `pet-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||
const built = buildPetitionFilePayload(petitionId, inner);
|
||||
const res = await signAndAppend({
|
||||
event_type: built.event_type,
|
||||
payload: built.payload,
|
||||
});
|
||||
if (res.ok) {
|
||||
setFileResult({ ok: true, eventId: res.event.event_id });
|
||||
onFiled?.();
|
||||
} else {
|
||||
setFileResult({ ok: false, reason: res.reason });
|
||||
}
|
||||
} catch (err) {
|
||||
setFileResult({
|
||||
ok: false,
|
||||
reason: err instanceof Error ? err.message : 'unknown_error',
|
||||
});
|
||||
} finally {
|
||||
setFiling(false);
|
||||
}
|
||||
}, [buildPayload, onFiled]);
|
||||
|
||||
return (
|
||||
<div className="border border-cyan-900/50 bg-cyan-900/5 p-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FileText size={14} className="text-cyan-400" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-cyan-400">
|
||||
File or Preview a Petition
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-3">
|
||||
<span className="text-cyan-400 font-bold">Preview</span> runs the
|
||||
governance DSL executor without touching the chain — the diagnostic
|
||||
on failure is shown verbatim.
|
||||
{' '}<span className="text-amber-400 font-bold">File</span> signs the
|
||||
same payload with your local node key and posts it to{' '}
|
||||
<span className="font-mono">/api/infonet/append</span>; the secure
|
||||
entry point ({' '}<span className="font-mono">Infonet.append</span>)
|
||||
verifies signature, replay, sequence, and binding before the event
|
||||
lands.
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 mb-1 block">CONFIG key</label>
|
||||
<input
|
||||
type="text"
|
||||
value={paramKey}
|
||||
onChange={(e) => setParamKey(e.target.value)}
|
||||
placeholder="e.g. vote_decay_days"
|
||||
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-sm text-white font-mono focus:border-cyan-500 focus:outline-none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 mb-1 block">New value</label>
|
||||
<input
|
||||
type="text"
|
||||
value={paramValue}
|
||||
onChange={(e) => setParamValue(e.target.value)}
|
||||
placeholder="e.g. 30 / true / argon2id"
|
||||
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-sm text-white font-mono focus:border-cyan-500 focus:outline-none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreview}
|
||||
disabled={previewing || !paramKey.trim()}
|
||||
className="px-3 py-1 bg-cyan-900/30 border border-cyan-700/50 text-cyan-400 hover:bg-cyan-900/50 hover:text-cyan-300 transition-colors text-xs uppercase tracking-wider disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{previewing ? 'Validating…' : 'Preview'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFile}
|
||||
disabled={filing || !paramKey.trim()}
|
||||
className="px-3 py-1 bg-amber-900/30 border border-amber-700/50 text-amber-400 hover:bg-amber-900/50 hover:text-amber-300 transition-colors text-xs uppercase tracking-wider disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Sign with the local node key + post to /api/infonet/append"
|
||||
>
|
||||
{filing ? 'Filing…' : 'File Petition'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{fileResult && fileResult.ok && (
|
||||
<div className="mt-3 border border-green-900/50 bg-green-900/10 p-2 text-xs">
|
||||
<div className="text-green-400 font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
|
||||
<CheckCircle2 size={12} /> PETITION FILED
|
||||
</div>
|
||||
<div className="text-gray-300 font-mono break-all">
|
||||
event_id: {fileResult.eventId}
|
||||
</div>
|
||||
<div className="text-gray-500 mt-1">
|
||||
The petition is now in the SIGNATURES phase. Other nodes can
|
||||
sign with <span className="font-mono">petition_sign</span>;
|
||||
voting opens once 25% oracle_rep_active worth of signatures land.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{fileResult && !fileResult.ok && (
|
||||
<div className="mt-3 border border-red-900/50 bg-red-900/10 p-2 text-xs">
|
||||
<div className="text-red-400 font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
|
||||
<AlertCircle size={12} /> FILING REJECTED
|
||||
</div>
|
||||
<div className="text-gray-300 font-mono break-all">{fileResult.reason}</div>
|
||||
<div className="text-gray-500 mt-1">
|
||||
Common causes: local identity not initialized
|
||||
(open the InfonetTerminal first), filer rep below
|
||||
petition_filing_cost, or the chain rejected the signed event.
|
||||
Use Preview first to confirm the payload validates.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewResult && previewResult.ok && (
|
||||
<div className="mt-3 border border-green-900/50 bg-green-900/10 p-2 text-xs">
|
||||
<div className="text-green-400 font-bold uppercase tracking-wider mb-1">
|
||||
VALIDATION PASSED
|
||||
</div>
|
||||
<div className="text-gray-300">
|
||||
Would change keys: {previewResult.changedKeys.map((k) => (
|
||||
<span key={k} className="text-cyan-400 font-mono mr-2">{k}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-gray-500 mt-1">
|
||||
Filing this petition costs the configured petition_filing_cost in common rep.
|
||||
Production filing requires a signed event — this is the validation preview only.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{previewResult && !previewResult.ok && (
|
||||
<div className="mt-3 border border-red-900/50 bg-red-900/10 p-2 text-xs">
|
||||
<div className="text-red-400 font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
|
||||
<AlertCircle size={12} /> VALIDATION REJECTED
|
||||
</div>
|
||||
<div className="text-gray-300 font-mono">{previewResult.reason}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PetitionsView({ onBack }: PetitionsViewProps) {
|
||||
const [petitions, setPetitions] = useState<PetitionState[] | null>(null);
|
||||
const [now, setNow] = useState(Date.now() / 1000);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchPetitions();
|
||||
setPetitions(data.petitions);
|
||||
setNow(data.now);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'network error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hasActivePhase = (petitions || []).some((p) =>
|
||||
p.status === 'signatures' || p.status === 'voting' || p.status === 'challenge',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [reload, hasActivePhase]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
if (!petitions) return null;
|
||||
const active = petitions.filter((p) =>
|
||||
['signatures', 'voting', 'challenge'].includes(p.status),
|
||||
);
|
||||
const passed = petitions.filter((p) =>
|
||||
['passed', 'executed'].includes(p.status),
|
||||
);
|
||||
const closed = petitions.filter((p) =>
|
||||
['failed_signatures', 'failed_vote', 'voided_challenge'].includes(p.status),
|
||||
);
|
||||
return { active, passed, closed };
|
||||
}, [petitions]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-400 hover:text-cyan-300 transition-colors text-sm"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
BACK TO TERMINAL
|
||||
</button>
|
||||
<div className="text-sm text-cyan-400 font-bold uppercase tracking-widest flex items-center gap-2">
|
||||
<Vote size={16} />
|
||||
BALLOT — Governance Petitions
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void reload()}
|
||||
disabled={loading}
|
||||
className="text-xs text-gray-500 hover:text-cyan-400 disabled:opacity-30"
|
||||
>
|
||||
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-3 space-y-6">
|
||||
<div className="text-xs text-gray-500 leading-relaxed">
|
||||
Petitions amend protocol parameters via the type-safe governance DSL.
|
||||
Lifecycle: <span className="text-cyan-400">SIGNATURES</span> (14d, 25% oracle_rep_active threshold)
|
||||
→ <span className="text-blue-400">VOTING</span> (7d, 67% supermajority + 30% quorum)
|
||||
→ <span className="text-amber-400">CHALLENGE</span> (48h constitutional challenge window)
|
||||
→ <span className="text-green-400">EXECUTED</span>.
|
||||
The DSL executor rejects unknown CONFIG keys, type mismatches, out-of-bounds
|
||||
values, and IMMUTABLE_PRINCIPLES writes — see the validation preview below.
|
||||
</div>
|
||||
|
||||
<FilePetitionForm onFiled={() => void reload()} />
|
||||
|
||||
{error && (
|
||||
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle size={12} />
|
||||
<span className="font-bold">Failed to load petitions</span>
|
||||
</div>
|
||||
<div className="text-gray-400 mt-1 font-mono">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped && grouped.active.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wider text-cyan-400 mb-2">
|
||||
Active Petitions ({grouped.active.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{grouped.active.map((p) => (
|
||||
<PetitionRow key={p.petition_id} petition={p} now={now} onAction={() => void reload()} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped && grouped.passed.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wider text-green-400 mb-2">
|
||||
Passed Petitions ({grouped.passed.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{grouped.passed.map((p) => (
|
||||
<PetitionRow key={p.petition_id} petition={p} now={now} onAction={() => void reload()} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped && grouped.closed.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wider text-gray-500 mb-2">
|
||||
Closed (Failed / Voided) ({grouped.closed.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{grouped.closed.map((p) => (
|
||||
<PetitionRow key={p.petition_id} petition={p} now={now} onAction={() => void reload()} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped && petitions && petitions.length === 0 && !loading && (
|
||||
<div className="border border-gray-800 bg-black/40 p-6 text-center">
|
||||
<div className="text-gray-500 text-sm mb-1">No petitions on the chain yet.</div>
|
||||
<div className="text-gray-600 text-xs">
|
||||
File one with the Preview tool above to see the lifecycle in action.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ChevronLeft, User, Eye, EyeOff, Wallet, Activity, ShieldCheck, AlertCircle } from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import { exportWormholeDmInvite } from '@/mesh/wormholeIdentityClient';
|
||||
|
||||
interface ProfileViewProps {
|
||||
onBack: () => void;
|
||||
@@ -49,6 +51,11 @@ export default function ProfileView({ onBack, persona, isCitizen, nodeId, public
|
||||
const [showTransactions, setShowTransactions] = useState(false);
|
||||
const [reputation, setReputation] = useState<ReputationSummary>(EMPTY_REPUTATION);
|
||||
const [oracleProfile, setOracleProfile] = useState<OracleProfileSummary>(EMPTY_ORACLE_PROFILE);
|
||||
const [dmInviteBusy, setDmInviteBusy] = useState(false);
|
||||
const [dmInviteBlob, setDmInviteBlob] = useState('');
|
||||
const [dmInviteQrSrc, setDmInviteQrSrc] = useState('');
|
||||
const [dmInviteFingerprint, setDmInviteFingerprint] = useState('');
|
||||
const [dmInviteStatus, setDmInviteStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -118,6 +125,40 @@ export default function ProfileView({ onBack, persona, isCitizen, nodeId, public
|
||||
};
|
||||
}, [nodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!dmInviteBlob) {
|
||||
setDmInviteQrSrc('');
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}
|
||||
|
||||
void QRCode.toDataURL(dmInviteBlob, {
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 1,
|
||||
width: 320,
|
||||
color: {
|
||||
dark: '#34d399',
|
||||
light: '#05080d',
|
||||
},
|
||||
})
|
||||
.then((dataUrl) => {
|
||||
if (active) {
|
||||
setDmInviteQrSrc(dataUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setDmInviteQrSrc('');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [dmInviteBlob]);
|
||||
|
||||
const displayNodeId = nodeId?.trim() || 'NOT PROVISIONED';
|
||||
const displayPersona = persona?.trim() || 'unassigned';
|
||||
const creditsReference = publicKey?.trim() || 'Not provisioned';
|
||||
@@ -141,6 +182,45 @@ export default function ProfileView({ onBack, persona, isCitizen, nodeId, public
|
||||
const oracleRepLocked = oracleProfile.oracle_rep_locked;
|
||||
const oracleProgress = oracleRepTotal > 0 ? Math.max(0, Math.min(100, (oracleRep / oracleRepTotal) * 100)) : 0;
|
||||
|
||||
const handleGenerateDmInvite = async () => {
|
||||
setDmInviteBusy(true);
|
||||
setDmInviteStatus(null);
|
||||
try {
|
||||
const exported = await exportWormholeDmInvite();
|
||||
setDmInviteBlob(JSON.stringify(exported, null, 2));
|
||||
setDmInviteFingerprint(String(exported.trust_fingerprint || ''));
|
||||
setDmInviteStatus({
|
||||
type: 'ok',
|
||||
text: 'Signed DM invite generated. Share it only over a trusted out-of-band channel.',
|
||||
});
|
||||
} catch (error) {
|
||||
setDmInviteStatus({
|
||||
type: 'err',
|
||||
text: error instanceof Error ? error.message : 'dm_invite_export_failed',
|
||||
});
|
||||
} finally {
|
||||
setDmInviteBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyDmInvite = async () => {
|
||||
if (!dmInviteBlob || !navigator?.clipboard?.writeText) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(dmInviteBlob);
|
||||
setDmInviteStatus({
|
||||
type: 'ok',
|
||||
text: 'Signed DM invite copied to clipboard.',
|
||||
});
|
||||
} catch (error) {
|
||||
setDmInviteStatus({
|
||||
type: 'err',
|
||||
text: error instanceof Error ? error.message : 'clipboard_write_failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div className="border-b border-gray-800 pb-4 mb-4 shrink-0">
|
||||
@@ -325,6 +405,76 @@ export default function ProfileView({ onBack, persona, isCitizen, nodeId, public
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex items-center">
|
||||
<ShieldCheck size={16} className="mr-2" /> FIRST-CONTACT BOOTSTRAP
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-400 leading-[1.7]">
|
||||
Export a signed DM invite for trusted out-of-band exchange. This pins first contact to
|
||||
your messaging identity instead of plain first-sight TOFU. It does not link wallet,
|
||||
reputation, or other personas.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={() => void handleGenerateDmInvite()}
|
||||
disabled={dmInviteBusy}
|
||||
className="px-4 py-2 border border-cyan-500/40 bg-cyan-950/20 text-cyan-300 text-xs tracking-[0.18em] uppercase disabled:opacity-50"
|
||||
>
|
||||
{dmInviteBusy ? 'Generating...' : 'Generate Signed DM Invite'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleCopyDmInvite()}
|
||||
disabled={!dmInviteBlob}
|
||||
className="px-4 py-2 border border-emerald-500/40 bg-emerald-950/20 text-emerald-300 text-xs tracking-[0.18em] uppercase disabled:opacity-50"
|
||||
>
|
||||
Copy Invite
|
||||
</button>
|
||||
</div>
|
||||
{dmInviteFingerprint && (
|
||||
<div className="text-sm text-emerald-300 font-mono">
|
||||
Trust fingerprint: {dmInviteFingerprint}
|
||||
</div>
|
||||
)}
|
||||
{dmInviteStatus && (
|
||||
<div
|
||||
className={`px-3 py-2 border text-sm ${
|
||||
dmInviteStatus.type === 'ok'
|
||||
? 'border-emerald-500/30 bg-emerald-950/20 text-emerald-300'
|
||||
: 'border-red-500/30 bg-red-950/20 text-red-300'
|
||||
}`}
|
||||
>
|
||||
{dmInviteStatus.text}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={dmInviteBlob}
|
||||
readOnly
|
||||
className="w-full min-h-[220px] bg-[#0a0a0a] border border-gray-800 px-4 py-3 text-sm text-gray-300 font-mono outline-none"
|
||||
placeholder="Generate a signed DM invite to display the export blob here."
|
||||
spellCheck={false}
|
||||
/>
|
||||
{dmInviteQrSrc && (
|
||||
<div className="border border-emerald-500/20 bg-[#0a0a0a] p-4">
|
||||
<div className="text-xs text-emerald-300 uppercase tracking-[0.18em] mb-3">
|
||||
QR Invite
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<img
|
||||
src={dmInviteQrSrc}
|
||||
alt="Signed DM invite QR"
|
||||
className="w-[320px] max-w-full border border-gray-800 bg-black p-3"
|
||||
/>
|
||||
<div className="text-xs text-gray-500 text-center leading-[1.65] max-w-[32rem]">
|
||||
Scan this over a trusted out-of-band channel. The QR carries the same signed DM
|
||||
invite shown above, including the trust fingerprint and signature envelope.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-800 bg-gray-900/20 p-4">
|
||||
<h2 className="text-cyan-400 font-bold mb-4 border-b border-gray-800 pb-2 flex items-center">
|
||||
<Wallet size={16} className="mr-2" /> CREDITS LEDGER
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ChevronLeft, FileText, Scale, Loader, AlertCircle, CheckCircle2, ShieldOff } from 'lucide-react';
|
||||
import {
|
||||
buildDisputeOpenPayload,
|
||||
buildDisputeStakePayload,
|
||||
buildEvidenceSubmitPayload,
|
||||
buildResolutionStakePayload,
|
||||
fetchMarketState,
|
||||
previewMarketResolution,
|
||||
signAndAppend,
|
||||
type AppendResult,
|
||||
type DisputeSummary,
|
||||
type MarketState,
|
||||
type ResolutionPreview,
|
||||
} from '@/mesh/infonetEconomyClient';
|
||||
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
|
||||
|
||||
interface ResolutionViewProps {
|
||||
marketId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const PHASE_STYLE: Record<string, { color: string; label: string }> = {
|
||||
predicting: { color: 'text-cyan-400', label: 'PREDICTING' },
|
||||
evidence: { color: 'text-amber-400', label: 'EVIDENCE WINDOW' },
|
||||
resolving: { color: 'text-blue-400', label: 'RESOLVING' },
|
||||
final: { color: 'text-green-400', label: 'FINAL' },
|
||||
invalid: { color: 'text-red-400', label: 'INVALID' },
|
||||
};
|
||||
|
||||
function DisputeRow({
|
||||
dispute,
|
||||
onAction,
|
||||
}: {
|
||||
dispute: DisputeSummary;
|
||||
onAction: () => void;
|
||||
}) {
|
||||
const [side, setSide] = useState<'confirm' | 'reverse'>('reverse');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [repType, setRepType] = useState<'oracle' | 'common'>('oracle');
|
||||
const action = useSignAndAppend();
|
||||
const busy = action.state === 'submitting';
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
const amt = Number(amount);
|
||||
if (!Number.isFinite(amt) || amt <= 0) return;
|
||||
const built = buildDisputeStakePayload(dispute.dispute_id, side, amt, repType);
|
||||
const res = await action.submit(built.event_type, built.payload);
|
||||
if (res.ok) {
|
||||
setAmount('');
|
||||
onAction();
|
||||
}
|
||||
}, [amount, side, repType, dispute.dispute_id, action, onAction]);
|
||||
|
||||
return (
|
||||
<div className="border border-red-900/50 bg-red-900/10 p-2 text-xs">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-red-400 font-bold">DISPUTE</span>
|
||||
{dispute.is_resolved ? (
|
||||
<span
|
||||
className={
|
||||
dispute.resolved_outcome === 'reversed' ? 'text-red-400' : 'text-green-400'
|
||||
}
|
||||
>
|
||||
{dispute.resolved_outcome?.toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-400">PENDING</span>
|
||||
)}
|
||||
<span className="text-gray-500 font-mono truncate">
|
||||
{dispute.dispute_id.slice(0, 12)}…
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-gray-300">
|
||||
Challenger: <span className="font-mono">{dispute.challenger_id.slice(0, 12)}…</span>
|
||||
{' — stake '}{dispute.challenger_stake.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-gray-500 mt-1">
|
||||
confirm: {dispute.confirm_stakes.length} stakes •
|
||||
reverse: {dispute.reverse_stakes.length} stakes
|
||||
</div>
|
||||
|
||||
{!dispute.is_resolved && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||
<select
|
||||
value={side}
|
||||
onChange={(e) => setSide(e.target.value as 'confirm' | 'reverse')}
|
||||
title="Dispute stake side"
|
||||
aria-label="Dispute stake side"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
>
|
||||
<option value="confirm">CONFIRM</option>
|
||||
<option value="reverse">REVERSE</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="amount"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-24"
|
||||
/>
|
||||
<select
|
||||
value={repType}
|
||||
onChange={(e) => setRepType(e.target.value as 'oracle' | 'common')}
|
||||
title="Reputation type to stake"
|
||||
aria-label="Reputation type to stake"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
>
|
||||
<option value="oracle">oracle</option>
|
||||
<option value="common">common</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={busy || !amount}
|
||||
className="px-2 py-1 uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
|
||||
>
|
||||
{busy ? 'Staking…' : 'Stake'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action.result && !action.result.ok && (
|
||||
<div className="text-red-400 font-mono mt-2 break-all">
|
||||
<AlertCircle size={10} className="inline mr-1" />
|
||||
{action.result.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResolutionView({ marketId, onBack }: ResolutionViewProps) {
|
||||
const [state, setState] = useState<MarketState | null>(null);
|
||||
const [preview, setPreview] = useState<ResolutionPreview['preview'] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Resolution-stake form state.
|
||||
const [stakeSide, setStakeSide] = useState<'yes' | 'no' | 'data_unavailable'>('yes');
|
||||
const [stakeAmount, setStakeAmount] = useState('');
|
||||
const [stakeRepType, setStakeRepType] = useState<'oracle' | 'common'>('oracle');
|
||||
|
||||
// Dispute-open form state.
|
||||
const [disputeStake, setDisputeStake] = useState('');
|
||||
const [disputeReason, setDisputeReason] = useState('');
|
||||
|
||||
// Evidence-submit form state (active during EVIDENCE phase).
|
||||
const [evidenceOutcome, setEvidenceOutcome] = useState<'yes' | 'no'>('yes');
|
||||
const [evidenceSourceDesc, setEvidenceSourceDesc] = useState('');
|
||||
const [evidenceHashesInput, setEvidenceHashesInput] = useState('');
|
||||
const [evidenceBond, setEvidenceBond] = useState('2');
|
||||
const [evidenceSubmitting, setEvidenceSubmitting] = useState(false);
|
||||
const [evidenceResult, setEvidenceResult] = useState<AppendResult | null>(null);
|
||||
|
||||
const stakeAction = useSignAndAppend();
|
||||
const disputeAction = useSignAndAppend();
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [s, p] = await Promise.all([
|
||||
fetchMarketState(marketId),
|
||||
previewMarketResolution(marketId).catch(() => null),
|
||||
]);
|
||||
setState(s);
|
||||
setPreview(p?.preview ?? null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'network error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [marketId]);
|
||||
|
||||
const submitStake = useCallback(async () => {
|
||||
const amt = Number(stakeAmount);
|
||||
if (!Number.isFinite(amt) || amt <= 0) return;
|
||||
const built = buildResolutionStakePayload(marketId, stakeSide, amt, stakeRepType);
|
||||
const res = await stakeAction.submit(built.event_type, built.payload);
|
||||
if (res.ok) {
|
||||
setStakeAmount('');
|
||||
void reload();
|
||||
}
|
||||
}, [stakeAmount, stakeSide, stakeRepType, marketId, stakeAction, reload]);
|
||||
|
||||
const submitDispute = useCallback(async () => {
|
||||
const stake = Number(disputeStake);
|
||||
if (!Number.isFinite(stake) || stake <= 0) return;
|
||||
if (!disputeReason.trim()) return;
|
||||
const built = buildDisputeOpenPayload(marketId, stake, disputeReason.trim());
|
||||
const res = await disputeAction.submit(built.event_type, built.payload);
|
||||
if (res.ok) {
|
||||
setDisputeStake('');
|
||||
setDisputeReason('');
|
||||
void reload();
|
||||
}
|
||||
}, [disputeStake, disputeReason, marketId, disputeAction, reload]);
|
||||
|
||||
const submitEvidence = useCallback(async () => {
|
||||
if (!evidenceSourceDesc.trim()) return;
|
||||
const hashes = evidenceHashesInput
|
||||
.split(/[,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (hashes.length === 0) return;
|
||||
const bond = Number(evidenceBond);
|
||||
if (!Number.isFinite(bond) || bond < 0) return;
|
||||
setEvidenceSubmitting(true);
|
||||
setEvidenceResult(null);
|
||||
try {
|
||||
const built = await buildEvidenceSubmitPayload({
|
||||
marketId,
|
||||
claimedOutcome: evidenceOutcome,
|
||||
evidenceHashes: hashes,
|
||||
sourceDescription: evidenceSourceDesc.trim(),
|
||||
bond,
|
||||
});
|
||||
const res = await signAndAppend({
|
||||
event_type: built.event_type,
|
||||
payload: built.payload,
|
||||
});
|
||||
setEvidenceResult(res);
|
||||
if (res.ok) {
|
||||
setEvidenceSourceDesc('');
|
||||
setEvidenceHashesInput('');
|
||||
void reload();
|
||||
}
|
||||
} catch (err) {
|
||||
setEvidenceResult({
|
||||
ok: false,
|
||||
reason: err instanceof Error ? err.message : 'unknown_error',
|
||||
});
|
||||
} finally {
|
||||
setEvidenceSubmitting(false);
|
||||
}
|
||||
}, [
|
||||
evidenceOutcome, evidenceSourceDesc, evidenceHashesInput, evidenceBond,
|
||||
marketId, reload,
|
||||
]);
|
||||
|
||||
const phase = state ? PHASE_STYLE[state.status] : null;
|
||||
const inEvidence = state?.status === 'evidence';
|
||||
const inResolving = state?.status === 'resolving';
|
||||
const isFinal = state?.status === 'final';
|
||||
const hasActivePhase = inEvidence || inResolving || isFinal;
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [reload, hasActivePhase]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
|
||||
<button onClick={onBack} className="flex items-center text-cyan-400 hover:text-cyan-300 text-sm">
|
||||
<ChevronLeft size={14} className="mr-1" /> BACK
|
||||
</button>
|
||||
<div className="text-sm text-cyan-400 font-bold uppercase tracking-widest flex items-center gap-2">
|
||||
<Scale size={16} /> RESOLUTION — {marketId}
|
||||
</div>
|
||||
<button onClick={() => void reload()} disabled={loading} className="text-xs text-gray-500 hover:text-cyan-400 disabled:opacity-30">
|
||||
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
|
||||
{error && (
|
||||
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
|
||||
<AlertCircle size={12} className="inline mr-1" />{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state && phase && (
|
||||
<div className="border border-gray-800 bg-black/40 p-3">
|
||||
<div className={`text-xs font-bold uppercase tracking-wider ${phase.color} mb-2`}>
|
||||
PHASE: {phase.label}
|
||||
{state.was_reversed && (
|
||||
<span className="ml-2 text-red-400">⚠ REVERSED BY DISPUTE</span>
|
||||
)}
|
||||
</div>
|
||||
{state.snapshot && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<div className="text-gray-500">Frozen Predictors</div>
|
||||
<div className="text-white">{(state.snapshot.frozen_participant_count as number) ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Frozen Total Stake</div>
|
||||
<div className="text-white">{(state.snapshot.frozen_total_stake as number)?.toFixed?.(2) ?? '0.00'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Excluded Predictors</div>
|
||||
<div className="text-white">{state.excluded_predictor_ids.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state && state.evidence_bundles.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wider text-amber-400 mb-2 flex items-center gap-1">
|
||||
<FileText size={12} /> Evidence Bundles ({state.evidence_bundles.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{state.evidence_bundles.map((b) => (
|
||||
<div key={b.submission_hash} className="border border-gray-800 bg-black/40 p-2 text-xs">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className={`font-bold ${b.claimed_outcome === 'yes' ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{b.claimed_outcome.toUpperCase()}
|
||||
</span>
|
||||
{b.is_first_for_side && (
|
||||
<span className="text-amber-400 text-xs">★ FIRST-FOR-SIDE BONUS</span>
|
||||
)}
|
||||
<span className="text-gray-500 font-mono truncate">
|
||||
{b.node_id.slice(0, 12)}…
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-gray-300 mb-1">{b.source_description || '(no description)'}</div>
|
||||
<div className="text-gray-500 font-mono">
|
||||
bond: {b.bond} • {b.evidence_hashes.length} hash{b.evidence_hashes.length === 1 ? '' : 'es'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state && state.disputes.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wider text-red-400 mb-2 flex items-center gap-1">
|
||||
<ShieldOff size={12} /> Disputes ({state.disputes.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{state.disputes.map((d) => (
|
||||
<DisputeRow
|
||||
key={d.dispute_id}
|
||||
dispute={d}
|
||||
onAction={() => void reload()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inEvidence && (
|
||||
<div className="border border-amber-900/50 bg-amber-900/5 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-amber-400 mb-2 flex items-center gap-1">
|
||||
<FileText size={12} /> Submit Evidence
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-2">
|
||||
Pay an evidence bond (≥
|
||||
<span className="font-mono"> evidence_bond_cost</span>{' '}
|
||||
oracle rep). The bond is returned if your claimed side wins;
|
||||
forfeited otherwise. The first submitter per side gets a small
|
||||
bonus from the losing pool when the market resolves on their
|
||||
side. Hashes use the canonical content + submission scheme;
|
||||
both are computed locally before signing.
|
||||
</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={evidenceOutcome}
|
||||
onChange={(e) => setEvidenceOutcome(e.target.value as 'yes' | 'no')}
|
||||
title="Claimed outcome"
|
||||
aria-label="Claimed outcome"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
>
|
||||
<option value="yes">YES</option>
|
||||
<option value="no">NO</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={evidenceBond}
|
||||
onChange={(e) => setEvidenceBond(e.target.value)}
|
||||
placeholder="bond"
|
||||
title="Bond amount in oracle rep"
|
||||
aria-label="Bond amount"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-24"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitEvidence}
|
||||
disabled={
|
||||
evidenceSubmitting ||
|
||||
!evidenceSourceDesc.trim() ||
|
||||
!evidenceHashesInput.trim()
|
||||
}
|
||||
className="px-3 py-1 uppercase tracking-wider border border-amber-700/50 bg-amber-900/20 text-amber-400 hover:bg-amber-900/40 disabled:opacity-30"
|
||||
>
|
||||
{evidenceSubmitting ? 'Submitting…' : 'Submit Evidence'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={evidenceSourceDesc}
|
||||
onChange={(e) => setEvidenceSourceDesc(e.target.value)}
|
||||
placeholder="source description (what + where)"
|
||||
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={evidenceHashesInput}
|
||||
onChange={(e) => setEvidenceHashesInput(e.target.value)}
|
||||
placeholder="evidence hashes (comma- or space-separated; ipfs://… or sha256:…)"
|
||||
className="w-full bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
/>
|
||||
</div>
|
||||
{evidenceResult && !evidenceResult.ok && (
|
||||
<div className="text-xs text-red-400 font-mono mt-2 break-all">
|
||||
<AlertCircle size={10} className="inline mr-1" />
|
||||
{evidenceResult.reason}
|
||||
</div>
|
||||
)}
|
||||
{evidenceResult && evidenceResult.ok && (
|
||||
<div className="text-xs text-green-400 font-mono mt-2 break-all">
|
||||
<CheckCircle2 size={10} className="inline mr-1" />
|
||||
evidence submitted — event_id {String(evidenceResult.event.event_id).slice(0, 16)}…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inResolving && (
|
||||
<div className="border border-blue-900/50 bg-blue-900/5 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-blue-400 mb-2 flex items-center gap-1">
|
||||
<Scale size={12} /> Stake on Resolution
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-2">
|
||||
Pick a side and stake oracle (or common) rep. ≥75% of oracle stake on
|
||||
one side reaches supermajority. <span className="text-amber-400">data_unavailable</span>{' '}
|
||||
triggers phantom-evidence slashing if it crosses 33%.
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<select
|
||||
value={stakeSide}
|
||||
onChange={(e) => setStakeSide(e.target.value as 'yes' | 'no' | 'data_unavailable')}
|
||||
title="Resolution stake side"
|
||||
aria-label="Resolution stake side"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
>
|
||||
<option value="yes">YES</option>
|
||||
<option value="no">NO</option>
|
||||
<option value="data_unavailable">DATA_UNAVAILABLE</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={stakeAmount}
|
||||
onChange={(e) => setStakeAmount(e.target.value)}
|
||||
placeholder="amount"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-32"
|
||||
/>
|
||||
<select
|
||||
value={stakeRepType}
|
||||
onChange={(e) => setStakeRepType(e.target.value as 'oracle' | 'common')}
|
||||
title="Reputation type to stake"
|
||||
aria-label="Reputation type to stake"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono"
|
||||
>
|
||||
<option value="oracle">oracle rep</option>
|
||||
<option value="common">common rep</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitStake}
|
||||
disabled={stakeAction.state === 'submitting' || !stakeAmount}
|
||||
className="px-3 py-1 uppercase tracking-wider border border-blue-700/50 bg-blue-900/20 text-blue-400 hover:bg-blue-900/40 disabled:opacity-30"
|
||||
>
|
||||
{stakeAction.state === 'submitting' ? 'Staking…' : 'Stake'}
|
||||
</button>
|
||||
</div>
|
||||
{stakeAction.result && !stakeAction.result.ok && (
|
||||
<div className="text-xs text-red-400 font-mono mt-2 break-all">
|
||||
<AlertCircle size={10} className="inline mr-1" />
|
||||
{stakeAction.result.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFinal && (
|
||||
<div className="border border-red-900/50 bg-red-900/5 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-red-400 mb-2 flex items-center gap-1">
|
||||
<ShieldOff size={12} /> Open a Dispute
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-2">
|
||||
Bounded reversal: a successful dispute flips the effective outcome of
|
||||
THIS market only — never cascades to other markets. Oracle-rep simple
|
||||
majority decides; common rep can also be staked but doesn't decide
|
||||
the outcome.
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={disputeStake}
|
||||
onChange={(e) => setDisputeStake(e.target.value)}
|
||||
placeholder="challenger stake"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono w-32"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={disputeReason}
|
||||
onChange={(e) => setDisputeReason(e.target.value)}
|
||||
placeholder="reason (max 2000 chars)"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono flex-1 min-w-[200px]"
|
||||
maxLength={2000}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitDispute}
|
||||
disabled={
|
||||
disputeAction.state === 'submitting' ||
|
||||
!disputeStake || !disputeReason.trim()
|
||||
}
|
||||
className="px-3 py-1 uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
|
||||
>
|
||||
{disputeAction.state === 'submitting' ? 'Opening…' : 'Open Dispute'}
|
||||
</button>
|
||||
</div>
|
||||
{disputeAction.result && !disputeAction.result.ok && (
|
||||
<div className="text-xs text-red-400 font-mono mt-2 break-all">
|
||||
<AlertCircle size={10} className="inline mr-1" />
|
||||
{disputeAction.result.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div className="border border-cyan-900/50 bg-cyan-900/5 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-cyan-400 mb-2 flex items-center gap-1">
|
||||
<CheckCircle2 size={12} /> Resolution Preview (if closed now)
|
||||
</div>
|
||||
<div className="text-sm mb-2">
|
||||
Outcome: <span className={
|
||||
preview.outcome === 'yes' ? 'text-green-400 font-bold' :
|
||||
preview.outcome === 'no' ? 'text-red-400 font-bold' :
|
||||
'text-gray-400 font-bold'
|
||||
}>{preview.outcome.toUpperCase()}</span>
|
||||
<span className="text-gray-500 ml-2 font-mono">({preview.reason})</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-gray-400">
|
||||
<div>
|
||||
Winners: {preview.stake_winnings.length} stake winnings,
|
||||
{' '}{preview.bond_returns.length} bond returns
|
||||
</div>
|
||||
<div>
|
||||
Forfeited: {preview.bond_forfeits.length} bonds •
|
||||
{' '}Burned: {preview.burned_amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
{preview.first_submitter_bonuses.length > 0 && (
|
||||
<div className="text-xs text-amber-400 mt-1">
|
||||
★ First-submitter bonuses: {preview.first_submitter_bonuses.map(b => `${b.node_id.slice(0,8)}…(${b.amount.toFixed(2)})`).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ChevronLeft, GitBranch, Server, AlertCircle, CheckCircle2, Loader } from 'lucide-react';
|
||||
import {
|
||||
buildUpgradeProposePayload,
|
||||
buildUpgradeSignPayload,
|
||||
buildUpgradeSignalReadyPayload,
|
||||
buildUpgradeVotePayload,
|
||||
fetchUpgrades,
|
||||
freshLocalId,
|
||||
type UpgradeProposalSummary,
|
||||
} from '@/mesh/infonetEconomyClient';
|
||||
import { useSignAndAppend } from '@/hooks/useSignAndAppend';
|
||||
|
||||
interface UpgradeViewProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<string, { color: string; label: string }> = {
|
||||
signatures: { color: 'text-cyan-400', label: 'COLLECTING SIGNATURES' },
|
||||
voting: { color: 'text-blue-400', label: 'VOTING' },
|
||||
challenge: { color: 'text-amber-400', label: 'CHALLENGE WINDOW' },
|
||||
activation: { color: 'text-purple-400', label: 'AWAITING HEAVY-NODE READINESS' },
|
||||
activated: { color: 'text-green-500', label: 'ACTIVATED' },
|
||||
failed_signatures: { color: 'text-red-400', label: 'FAILED — SIGNATURES' },
|
||||
failed_vote: { color: 'text-red-400', label: 'FAILED — VOTE' },
|
||||
voided_challenge: { color: 'text-red-500', label: 'VOIDED BY CHALLENGE' },
|
||||
failed_activation: { color: 'text-red-400', label: 'FAILED — ACTIVATION' },
|
||||
not_found: { color: 'text-gray-500', label: 'NOT FOUND' },
|
||||
};
|
||||
|
||||
function UpgradeRow({
|
||||
proposal,
|
||||
onAction,
|
||||
}: {
|
||||
proposal: UpgradeProposalSummary;
|
||||
onAction: () => void;
|
||||
}) {
|
||||
const style = STATUS_STYLE[proposal.status] ?? STATUS_STYLE.not_found;
|
||||
const totalVotes = proposal.votes_for_weight + proposal.votes_against_weight;
|
||||
const yesPct = totalVotes > 0 ? (proposal.votes_for_weight / totalVotes) * 100 : 0;
|
||||
const readinessPct = (proposal.readiness_fraction || 0) * 100;
|
||||
const { state, result, submit } = useSignAndAppend();
|
||||
const busy = state === 'submitting';
|
||||
|
||||
const sign = useCallback(async () => {
|
||||
const built = buildUpgradeSignPayload(proposal.proposal_id);
|
||||
const res = await submit(built.event_type, built.payload);
|
||||
if (res.ok) onAction();
|
||||
}, [proposal.proposal_id, submit, onAction]);
|
||||
|
||||
const voteFor = useCallback(async () => {
|
||||
const built = buildUpgradeVotePayload(proposal.proposal_id, 'for');
|
||||
const res = await submit(built.event_type, built.payload);
|
||||
if (res.ok) onAction();
|
||||
}, [proposal.proposal_id, submit, onAction]);
|
||||
|
||||
const voteAgainst = useCallback(async () => {
|
||||
const built = buildUpgradeVotePayload(proposal.proposal_id, 'against');
|
||||
const res = await submit(built.event_type, built.payload);
|
||||
if (res.ok) onAction();
|
||||
}, [proposal.proposal_id, submit, onAction]);
|
||||
|
||||
const signalReady = useCallback(async () => {
|
||||
const built = buildUpgradeSignalReadyPayload(
|
||||
proposal.proposal_id,
|
||||
proposal.release_hash,
|
||||
);
|
||||
const res = await submit(built.event_type, built.payload);
|
||||
if (res.ok) onAction();
|
||||
}, [proposal.proposal_id, proposal.release_hash, submit, onAction]);
|
||||
|
||||
return (
|
||||
<div className="border border-gray-800 bg-black/40 p-3">
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch size={14} className={style.color} />
|
||||
<span className={`text-xs font-bold uppercase tracking-wider ${style.color}`}>
|
||||
{style.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 font-mono">
|
||||
→ v{proposal.target_protocol_version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-400 mb-2 font-mono break-all">
|
||||
release_hash: {proposal.release_hash.slice(0, 32)}…
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs mb-2">
|
||||
<div>
|
||||
<div className="text-gray-500">Proposer</div>
|
||||
<div className="text-gray-300 font-mono truncate">
|
||||
{proposal.proposer_id.slice(0, 16)}…
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Filed</div>
|
||||
<div className="text-gray-300">
|
||||
{proposal.filed_at ? new Date(proposal.filed_at * 1000).toLocaleDateString() : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(proposal.status === 'voting' || proposal.status === 'challenge'
|
||||
|| proposal.status === 'activation' || proposal.status === 'activated') && (
|
||||
<>
|
||||
<div className="text-xs text-gray-500 mb-1">
|
||||
Vote: {proposal.votes_for_weight.toFixed(1)} for / {proposal.votes_against_weight.toFixed(1)} against
|
||||
<span className="text-gray-600 ml-2">(80% supermajority required)</span>
|
||||
</div>
|
||||
<div className="h-1 bg-gray-800 mb-2 overflow-hidden flex">
|
||||
<div className="h-full bg-green-500" style={{ width: `${yesPct}%` }} />
|
||||
<div className="h-full bg-red-500" style={{ width: `${100 - yesPct}%` }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(proposal.status === 'activation' || proposal.status === 'activated') && (
|
||||
<>
|
||||
<div className="text-xs text-purple-400 mb-1 flex items-center gap-1">
|
||||
<Server size={11} />
|
||||
Heavy-Node readiness: {readinessPct.toFixed(1)}%
|
||||
<span className="text-gray-500 ml-2">(67% required for activation)</span>
|
||||
{proposal.readiness_threshold_met && (
|
||||
<span className="text-green-400 ml-2">✓ THRESHOLD MET</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1 bg-gray-800 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-500 transition-all"
|
||||
style={{ width: `${Math.min(100, readinessPct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{proposal.status === 'signatures' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={sign}
|
||||
disabled={busy}
|
||||
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-purple-700/50 bg-purple-900/20 text-purple-400 hover:bg-purple-900/40 disabled:opacity-30"
|
||||
>
|
||||
{busy ? 'Signing…' : 'Sign'}
|
||||
</button>
|
||||
)}
|
||||
{proposal.status === 'voting' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={voteFor}
|
||||
disabled={busy}
|
||||
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-green-700/50 bg-green-900/20 text-green-400 hover:bg-green-900/40 disabled:opacity-30"
|
||||
>
|
||||
{busy ? '…' : 'Vote FOR'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={voteAgainst}
|
||||
disabled={busy}
|
||||
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-red-700/50 bg-red-900/20 text-red-400 hover:bg-red-900/40 disabled:opacity-30"
|
||||
>
|
||||
{busy ? '…' : 'Vote AGAINST'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{proposal.status === 'activation' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={signalReady}
|
||||
disabled={busy}
|
||||
title="Signal that this Heavy Node has installed and verified the new release"
|
||||
className="px-2 py-0.5 text-xs uppercase tracking-wider border border-purple-700/50 bg-purple-900/20 text-purple-400 hover:bg-purple-900/40 disabled:opacity-30"
|
||||
>
|
||||
{busy ? '…' : 'Signal Ready'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result && !result.ok && (
|
||||
<div className="text-xs text-red-400 font-mono mt-2 break-all">
|
||||
<AlertCircle size={10} className="inline mr-1" />
|
||||
{result.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProposeUpgradePanel({ onAction }: { onAction: () => void }) {
|
||||
const [releaseHash, setReleaseHash] = useState('');
|
||||
const [releaseDescription, setReleaseDescription] = useState('');
|
||||
const [targetProtocolVersion, setTargetProtocolVersion] = useState('');
|
||||
const { state, result, submit } = useSignAndAppend();
|
||||
const busy = state === 'submitting';
|
||||
|
||||
const propose = useCallback(async () => {
|
||||
const trimmedHash = releaseHash.trim().toLowerCase();
|
||||
const trimmedDesc = releaseDescription.trim();
|
||||
const trimmedVersion = targetProtocolVersion.trim();
|
||||
if (trimmedHash.length !== 64 || !/^[0-9a-f]{64}$/.test(trimmedHash)) return;
|
||||
if (!trimmedDesc) return;
|
||||
if (!trimmedVersion) return;
|
||||
const built = buildUpgradeProposePayload({
|
||||
proposalId: freshLocalId('upg'),
|
||||
releaseHash: trimmedHash,
|
||||
releaseDescription: trimmedDesc,
|
||||
targetProtocolVersion: trimmedVersion,
|
||||
});
|
||||
const res = await submit(built.event_type, built.payload);
|
||||
if (res.ok) {
|
||||
setReleaseHash('');
|
||||
setReleaseDescription('');
|
||||
setTargetProtocolVersion('');
|
||||
onAction();
|
||||
}
|
||||
}, [releaseHash, releaseDescription, targetProtocolVersion, submit, onAction]);
|
||||
|
||||
return (
|
||||
<div className="border border-purple-900/50 bg-purple-900/5 p-3">
|
||||
<div className="text-xs uppercase tracking-wider text-purple-400 font-bold mb-2">
|
||||
File Upgrade Proposal
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-3 leading-relaxed">
|
||||
Filing requires <span className="text-purple-400">upgrade_filing_cost</span> common rep and a
|
||||
SHA-256 hash of the verified release artifact. After filing, the proposal collects
|
||||
signatures, then enters voting (80% supermajority / 40% quorum), then the challenge window,
|
||||
then awaits 67% Heavy-Node readiness signal before activation.
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={releaseHash}
|
||||
onChange={(e) => setReleaseHash(e.target.value)}
|
||||
placeholder="release_hash (64 hex chars)"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono text-xs col-span-1 md:col-span-2"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={targetProtocolVersion}
|
||||
onChange={(e) => setTargetProtocolVersion(e.target.value)}
|
||||
placeholder="target protocol_version"
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
value={releaseDescription}
|
||||
onChange={(e) => setReleaseDescription(e.target.value)}
|
||||
placeholder="release_description — what changes / event types / formulas does this introduce?"
|
||||
rows={2}
|
||||
className="bg-black/60 border border-gray-700 px-2 py-1 text-white text-xs w-full mb-2"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={propose}
|
||||
disabled={busy}
|
||||
className="px-3 py-1 text-xs uppercase tracking-wider border border-purple-700/50 bg-purple-900/20 text-purple-400 hover:bg-purple-900/40 disabled:opacity-30"
|
||||
>
|
||||
{busy ? 'Filing…' : 'Propose Upgrade'}
|
||||
</button>
|
||||
{result && result.ok && (
|
||||
<span className="text-xs text-green-400 flex items-center gap-1">
|
||||
<CheckCircle2 size={11} /> Filed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{result && !result.ok && (
|
||||
<div className="text-xs text-red-400 font-mono mt-2 break-all">
|
||||
<AlertCircle size={10} className="inline mr-1" />
|
||||
{result.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UpgradeView({ onBack }: UpgradeViewProps) {
|
||||
const [upgrades, setUpgrades] = useState<UpgradeProposalSummary[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchUpgrades();
|
||||
setUpgrades(data.upgrades);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'network error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hasActivePhase = (upgrades || []).some((u) =>
|
||||
u.status === 'signatures' || u.status === 'voting' ||
|
||||
u.status === 'challenge' || u.status === 'activation',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
const interval = setInterval(() => void reload(), hasActivePhase ? 8_000 : 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [reload, hasActivePhase]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-gray-800/50 pb-3 mb-4 shrink-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-cyan-400 hover:text-cyan-300 transition-colors text-sm"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" />
|
||||
BACK
|
||||
</button>
|
||||
<div className="text-sm text-purple-400 font-bold uppercase tracking-widest flex items-center gap-2">
|
||||
<GitBranch size={16} />
|
||||
UPGRADE-HASH GOVERNANCE
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void reload()}
|
||||
disabled={loading}
|
||||
className="text-xs text-gray-500 hover:text-purple-400 disabled:opacity-30"
|
||||
>
|
||||
{loading ? <Loader size={12} className="animate-spin" /> : 'REFRESH'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-3 space-y-4">
|
||||
<div className="text-xs text-gray-500 leading-relaxed">
|
||||
Protocol upgrades that need new logic / new event types / new formulas
|
||||
can't be expressed as parameter changes — they use upgrade-hash
|
||||
governance. The network votes on a software release's SHA-256 hash;
|
||||
Heavy Nodes that have downloaded and verified the release emit
|
||||
<span className="text-purple-400"> upgrade_signal_ready</span>. Once 67%
|
||||
of Heavy Nodes have signaled, the upgrade activates and protocol_version
|
||||
increments. Higher thresholds than param petitions: <span className="text-green-400">80% supermajority</span>,
|
||||
<span className="text-blue-400"> 40% quorum</span>,
|
||||
<span className="text-purple-400"> 67% Heavy-Node activation</span>.
|
||||
</div>
|
||||
|
||||
<ProposeUpgradePanel onAction={() => void reload()} />
|
||||
|
||||
{error && (
|
||||
<div className="border border-red-900/50 bg-red-900/10 p-3 text-xs text-red-400">
|
||||
<AlertCircle size={12} className="inline mr-1" />
|
||||
<span className="font-bold">Failed to load:</span>
|
||||
<span className="text-gray-400 ml-2 font-mono">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{upgrades && upgrades.length === 0 && !loading && (
|
||||
<div className="border border-gray-800 bg-black/40 p-6 text-center">
|
||||
<div className="text-gray-500 text-sm">No upgrade proposals on chain.</div>
|
||||
<div className="text-gray-600 text-xs mt-1">
|
||||
Filing requires <span className="text-purple-400">upgrade_filing_cost</span> common rep
|
||||
and a SHA-256 release hash.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{upgrades?.map((u) => (
|
||||
<UpgradeRow key={u.proposal_id} proposal={u} onAction={() => void reload()} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,17 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const LOCATIONS = [
|
||||
{ name: 'Night City', tz: 'America/Los_Angeles', tempC: 18 },
|
||||
{ name: 'Tokyo', tz: 'Asia/Tokyo', tempC: 22 },
|
||||
{ name: 'New York', tz: 'America/New_York', tempC: 25 },
|
||||
{ name: 'London', tz: 'Europe/London', tempC: 12 },
|
||||
{ name: 'Neo Seoul', tz: 'Asia/Seoul', tempC: 19 },
|
||||
];
|
||||
|
||||
export default function WeatherWidget() {
|
||||
const [locIdx, setLocIdx] = useState(0);
|
||||
const [isCelsius, setIsCelsius] = useState(false);
|
||||
const [time, setTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
@@ -20,32 +10,19 @@ export default function WeatherWidget() {
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const loc = LOCATIONS[locIdx];
|
||||
const temp = isCelsius ? loc.tempC : Math.round(loc.tempC * 9/5 + 32);
|
||||
const tempUnit = isCelsius ? 'C' : 'F';
|
||||
|
||||
const timeString = time.toLocaleTimeString('en-US', { timeZone: loc.tz, hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
const dateString = time.toLocaleDateString('en-US', { timeZone: loc.tz, month: 'short', day: 'numeric' });
|
||||
const timeString = time.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const dateString = time.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm md:text-xs text-gray-400 border border-gray-800 bg-gray-900/30 px-2 py-1 shrink-0 font-mono tracking-widest uppercase whitespace-nowrap">
|
||||
<span>{dateString} {timeString}</span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span
|
||||
className="cursor-pointer hover:text-white transition-colors"
|
||||
onClick={() => setLocIdx((i) => (i + 1) % LOCATIONS.length)}
|
||||
title="Change Location & Timezone"
|
||||
>
|
||||
{loc.name}
|
||||
</span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span
|
||||
className="cursor-pointer hover:text-white transition-colors"
|
||||
onClick={() => setIsCelsius(!isCelsius)}
|
||||
title="Toggle C / F"
|
||||
>
|
||||
{temp}°{tempUnit}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,15 @@ interface InfonetTerminalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpenLiveGate?: (gate: string) => void;
|
||||
onOpenDeadDrop?: (peerId: string, options?: { showSas?: boolean }) => void;
|
||||
}
|
||||
|
||||
export default function InfonetTerminal({ isOpen, onClose, onOpenLiveGate }: InfonetTerminalProps) {
|
||||
export default function InfonetTerminal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onOpenLiveGate,
|
||||
onOpenDeadDrop,
|
||||
}: InfonetTerminalProps) {
|
||||
/* Close on Escape */
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -68,7 +74,12 @@ export default function InfonetTerminal({ isOpen, onClose, onOpenLiveGate }: Inf
|
||||
|
||||
{/* Shell content — fills remaining space, scrolls internally */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<InfonetShell isOpen={isOpen} onClose={onClose} onOpenLiveGate={onOpenLiveGate} />
|
||||
<InfonetShell
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onOpenLiveGate={onOpenLiveGate}
|
||||
onOpenDeadDrop={onOpenDeadDrop}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const shortcuts = [
|
||||
{ key: 'L', desc: 'Toggle left panel (LAYERS)' },
|
||||
{ key: 'R', desc: 'Toggle right panel (INTEL)' },
|
||||
{ key: 'M', desc: 'Toggle markets ticker' },
|
||||
{ key: 'S', desc: 'Open settings' },
|
||||
{ key: 'K', desc: 'Open map legend (KEY)' },
|
||||
{ key: 'F', desc: 'Focus search bar' },
|
||||
{ key: 'Esc', desc: 'Deselect / close modals' },
|
||||
{ key: 'Space', desc: 'Toggle this overlay' },
|
||||
];
|
||||
|
||||
export default function KeyboardShortcutsOverlay({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[9500] flex items-center justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" />
|
||||
|
||||
{/* Content */}
|
||||
<motion.div
|
||||
className="relative z-10 bg-[var(--bg-primary)]/95 border border-[var(--border-secondary)] rounded-sm p-8 max-w-md w-full mx-4 shadow-[0_0_40px_rgba(6,182,212,0.1)]"
|
||||
initial={{ scale: 0.9, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 20 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-[18px] text-[var(--text-heading)] font-mono font-bold tracking-widest">
|
||||
KEYBOARD SHORTCUTS
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[var(--text-muted)] hover:text-cyan-400 transition-colors text-lg font-bold"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-[var(--border-primary)] mb-4" />
|
||||
|
||||
{/* Shortcuts Grid */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{shortcuts.map(({ key, desc }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between py-1.5"
|
||||
>
|
||||
<span className="text-[12px] font-mono text-[var(--text-primary)] tracking-wide">
|
||||
{desc}
|
||||
</span>
|
||||
<kbd className="inline-flex items-center justify-center min-w-[32px] px-2 py-1 rounded-sm bg-cyan-950/40 border border-cyan-800/50 text-[11px] font-mono font-bold text-cyan-400 tracking-wider">
|
||||
{key}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 pt-3 border-t border-[var(--border-primary)]">
|
||||
<div className="text-[9px] font-mono tracking-[0.25em] text-[var(--text-muted)] text-center uppercase">
|
||||
Shortcuts are disabled when typing in inputs
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
+1593
-1285
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,382 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { AlertTriangle, Play, Pause } from 'lucide-react';
|
||||
import HlsVideo, { type HlsVideoHandle } from '@/components/HlsVideo';
|
||||
|
||||
export interface CctvFullscreenModalProps {
|
||||
url: string;
|
||||
mediaType: string;
|
||||
isVideo: boolean;
|
||||
cameraName: string;
|
||||
sourceAgency: string;
|
||||
cameraId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CctvFullscreenModal({
|
||||
url,
|
||||
mediaType,
|
||||
isVideo,
|
||||
cameraName,
|
||||
sourceAgency,
|
||||
cameraId,
|
||||
onClose,
|
||||
}: CctvFullscreenModalProps) {
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [mediaError, setMediaError] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<HlsVideoHandle>(null);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (mediaType === 'hls') {
|
||||
if (hlsRef.current?.paused) hlsRef.current.play();
|
||||
else hlsRef.current?.pause();
|
||||
setPaused(!hlsRef.current?.paused);
|
||||
} else if (videoRef.current) {
|
||||
if (videoRef.current.paused) videoRef.current.play();
|
||||
else videoRef.current.pause();
|
||||
setPaused(videoRef.current.paused);
|
||||
}
|
||||
}, [mediaType]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.88)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '60px 20px 80px 20px',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}}
|
||||
tabIndex={-1}
|
||||
ref={(el) => el?.focus()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.95)',
|
||||
border: '1px solid rgba(8,145,178,0.5)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
maxWidth: 'calc(100vw - 40px)',
|
||||
maxHeight: 'calc(100vh - 80px)',
|
||||
width: 900,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 0 60px rgba(8,145,178,0.25), inset 0 0 30px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 16px',
|
||||
background: 'rgba(8,51,68,0.4)',
|
||||
borderBottom: '1px solid rgba(8,145,178,0.3)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<AlertTriangle size={12} style={{ color: '#ef4444' }} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: '#22d3ee',
|
||||
fontFamily: 'monospace',
|
||||
letterSpacing: '0.2em',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
OPTIC INTERCEPT
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: 'rgba(8,145,178,0.6)',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
ID: {cameraId}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'rgba(239,68,68,0.2)',
|
||||
border: '1px solid rgba(239,68,68,0.4)',
|
||||
borderRadius: 6,
|
||||
color: '#ef4444',
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
padding: '4px 10px',
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.1em',
|
||||
}}
|
||||
>
|
||||
✕ CLOSE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata row */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 16px',
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
borderBottom: '1px solid rgba(8,51,68,0.5)',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#22d3ee', letterSpacing: '0.15em' }}>{sourceAgency}</span>
|
||||
<span style={{ color: '#ef4444', letterSpacing: '0.1em', fontWeight: 'bold' }}>
|
||||
REC // {new Date().toLocaleTimeString('en-GB', { hour12: false })}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: 'rgba(8,145,178,0.7)',
|
||||
letterSpacing: '0.1em',
|
||||
background: 'rgba(8,145,178,0.1)',
|
||||
border: '1px solid rgba(8,145,178,0.2)',
|
||||
borderRadius: 4,
|
||||
padding: '2px 8px',
|
||||
}}
|
||||
>
|
||||
{mediaType.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Media area */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
background: '#000',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: 400,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{url ? (
|
||||
<>
|
||||
{mediaType === 'video' && !mediaError && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={url}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
onError={() => setMediaError(true)}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 'calc(100vh - 260px)',
|
||||
objectFit: 'contain',
|
||||
filter: 'contrast(1.25) saturate(0.5)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{mediaType === 'hls' && !mediaError && (
|
||||
<HlsVideo
|
||||
ref={hlsRef}
|
||||
url={url}
|
||||
onError={() => setMediaError(true)}
|
||||
className=""
|
||||
/>
|
||||
)}
|
||||
{mediaType === 'mjpeg' && (
|
||||
<img
|
||||
src={url}
|
||||
alt="MJPEG Feed"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 'calc(100vh - 260px)',
|
||||
objectFit: 'contain',
|
||||
filter: 'contrast(1.25) saturate(0.5)',
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(mediaType === 'image' || mediaType === 'satellite') && (
|
||||
<img
|
||||
src={url}
|
||||
alt="CCTV Feed"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 'calc(100vh - 260px)',
|
||||
objectFit: 'contain',
|
||||
filter: 'contrast(1.25) saturate(0.5)',
|
||||
}}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Media error fallback */}
|
||||
{mediaError && (
|
||||
<div style={{ fontSize: 11, color: 'rgba(239,68,68,0.7)', fontFamily: 'monospace', letterSpacing: '0.15em', textAlign: 'center', padding: 40 }}>
|
||||
FEED UNAVAILABLE<br />
|
||||
<span style={{ fontSize: 9, color: 'rgba(148,163,184,0.5)' }}>stream failed to load — source may be offline</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* REC overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 14,
|
||||
fontSize: 9,
|
||||
color: '#22d3ee',
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
padding: '2px 6px',
|
||||
fontFamily: 'monospace',
|
||||
letterSpacing: '0.1em',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
REC // 00:00:00:00
|
||||
</div>
|
||||
|
||||
{/* Play/Pause overlay for video streams */}
|
||||
{isVideo && (
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 14,
|
||||
right: 14,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
border: '1px solid rgba(8,145,178,0.5)',
|
||||
color: '#22d3ee',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.target as HTMLElement).style.background = 'rgba(8,51,68,0.8)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.target as HTMLElement).style.background = 'rgba(0,0,0,0.7)';
|
||||
}}
|
||||
>
|
||||
{paused ? <Play size={18} /> : <Pause size={18} />}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'rgba(8,145,178,0.4)',
|
||||
fontFamily: 'monospace',
|
||||
letterSpacing: '0.2em',
|
||||
}}
|
||||
>
|
||||
NO SIGNAL
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Location bar */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 16px',
|
||||
background: 'rgba(8,51,68,0.3)',
|
||||
borderTop: '1px solid rgba(8,145,178,0.2)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: '#22d3ee',
|
||||
fontFamily: 'monospace',
|
||||
letterSpacing: '0.15em',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{cameraName}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
{url && (
|
||||
<>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
background: 'rgba(8,145,178,0.2)',
|
||||
border: '1px solid rgba(8,145,178,0.5)',
|
||||
borderRadius: 6,
|
||||
color: '#22d3ee',
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
padding: '5px 14px',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
letterSpacing: '0.15em',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
OPEN SOURCE ↗
|
||||
</a>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(8,145,178,0.15)',
|
||||
border: '1px solid rgba(8,145,178,0.4)',
|
||||
borderRadius: 6,
|
||||
color: '#22d3ee',
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
padding: '5px 14px',
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.15em',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
COPY URL
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import type { CorrelationAlert } from '@/types/dashboard';
|
||||
|
||||
export interface CorrelationPopupProps {
|
||||
alert: CorrelationAlert;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, { label: string; color: string; border: string }> = {
|
||||
contradiction: { label: 'POSSIBLE CONTRADICTION', color: 'text-amber-400', border: 'border-amber-500/50' },
|
||||
rf_anomaly: { label: 'RF ANOMALY', color: 'text-gray-400', border: 'border-gray-500/50' },
|
||||
military_buildup: { label: 'MILITARY BUILDUP', color: 'text-red-400', border: 'border-red-500/50' },
|
||||
infra_cascade: { label: 'INFRASTRUCTURE CASCADE', color: 'text-blue-400', border: 'border-blue-500/50' },
|
||||
analysis_zone: { label: 'OPENCLAW ANALYSIS', color: 'text-cyan-400', border: 'border-cyan-500/50' },
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, { label: string; color: string }> = {
|
||||
contradiction: { label: 'CONTRADICTION', color: 'text-amber-400' },
|
||||
analysis: { label: 'ANALYSIS', color: 'text-cyan-400' },
|
||||
warning: { label: 'WARNING', color: 'text-red-400' },
|
||||
observation: { label: 'OBSERVATION', color: 'text-blue-400' },
|
||||
hypothesis: { label: 'HYPOTHESIS', color: 'text-purple-400' },
|
||||
};
|
||||
|
||||
const CONTEXT_COLORS: Record<string, string> = {
|
||||
STRONG: 'text-red-400',
|
||||
MODERATE: 'text-amber-400',
|
||||
WEAK: 'text-yellow-300',
|
||||
DETECTION_GAP: 'text-gray-400',
|
||||
};
|
||||
|
||||
const SEVERITY_BADGES: Record<string, { bg: string; text: string }> = {
|
||||
high: { bg: 'bg-red-900/50 border-red-500/40', text: 'text-red-300' },
|
||||
medium: { bg: 'bg-amber-900/50 border-amber-500/40', text: 'text-amber-300' },
|
||||
low: { bg: 'bg-gray-800/50 border-gray-500/40', text: 'text-gray-300' },
|
||||
};
|
||||
|
||||
export function CorrelationPopup({ alert, onClose }: CorrelationPopupProps) {
|
||||
const meta = TYPE_LABELS[alert.type] || TYPE_LABELS.contradiction;
|
||||
const sevBadge = SEVERITY_BADGES[alert.severity] || SEVERITY_BADGES.low;
|
||||
const isContradiction = alert.type === 'contradiction';
|
||||
const isAnalysisZone = alert.type === 'analysis_zone';
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!alert.id) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/ai/analysis-zones/${encodeURIComponent(alert.id)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popup
|
||||
longitude={alert.lng}
|
||||
latitude={alert.lat}
|
||||
closeButton={false}
|
||||
closeOnClick={false}
|
||||
onClose={onClose}
|
||||
anchor="bottom"
|
||||
offset={12}
|
||||
maxWidth="360px"
|
||||
>
|
||||
<div className={`map-popup border ${meta.border}`}>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
{isAnalysisZone ? (
|
||||
<>
|
||||
<div className={`map-popup-title ${meta.color}`}>
|
||||
{alert.title || 'OPENCLAW ANALYSIS'}
|
||||
</div>
|
||||
{alert.category && (
|
||||
<div className="text-[11px] font-mono tracking-widest mt-0.5">
|
||||
<span className={CATEGORY_LABELS[alert.category]?.color || 'text-cyan-400'}>
|
||||
{CATEGORY_LABELS[alert.category]?.label || alert.category.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={`map-popup-title ${meta.color}`}>
|
||||
!! {meta.label} !!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-[11px] font-mono tracking-widest px-1.5 py-0.5 rounded border ${sevBadge.bg} ${sevBadge.text}`}>
|
||||
{isAnalysisZone ? alert.severity?.toUpperCase() : `ALERT LVL ${alert.score}`}
|
||||
</span>
|
||||
{isAnalysisZone && alert.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="p-1 text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded transition disabled:opacity-50"
|
||||
title="Delete this analysis zone"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Analysis Zone: Agent report body ── */}
|
||||
{isAnalysisZone && alert.body && (
|
||||
<div className="mt-2 pt-2 border-t border-cyan-500/20">
|
||||
<div className="text-[11px] font-mono tracking-widest text-cyan-500/60 mb-1.5">AGENT ASSESSMENT</div>
|
||||
<div className="text-[10px] text-cyan-100/90 leading-relaxed whitespace-pre-wrap">
|
||||
{alert.body}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analysis Zone: Evidence/drivers */}
|
||||
{isAnalysisZone && alert.drivers && alert.drivers.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-cyan-500/15">
|
||||
<div className="text-[11px] font-mono tracking-widest text-cyan-500/50 mb-1.5">KEY INDICATORS</div>
|
||||
{alert.drivers.map((driver, i) => (
|
||||
<div key={i} className="text-[10px] text-cyan-200/70 mb-0.5 flex items-start gap-1">
|
||||
<span className="text-cyan-500">{i + 1}.</span> {driver}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analysis Zone: Source attribution */}
|
||||
{isAnalysisZone && (
|
||||
<div className="mt-2 pt-1.5 border-t border-cyan-500/10">
|
||||
<div className="text-[10px] text-cyan-500/40 text-center">
|
||||
Placed by OpenClaw agent — click trash icon to remove
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Legacy contradiction sections (kept for existing correlation types) ── */}
|
||||
|
||||
{/* Context rating for contradictions */}
|
||||
{isContradiction && alert.context && (
|
||||
<div className="map-popup-row mb-1">
|
||||
<span className="text-[#8899aa]">CONFIDENCE: </span>
|
||||
<span className={`font-bold ${CONTEXT_COLORS[alert.context] || 'text-white'}`}>{alert.context}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAnalysisZone && alert.location_name && (
|
||||
<div className="map-popup-row text-[#8899aa] mb-2">
|
||||
REGION: <span className="text-white">{alert.location_name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 1: The Statement/Claim */}
|
||||
{isContradiction && alert.headlines && alert.headlines.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-amber-500/20">
|
||||
<div className="text-[11px] font-mono tracking-widest text-amber-500/60 mb-1.5">OFFICIAL STATEMENT</div>
|
||||
{alert.headlines.map((headline, i) => (
|
||||
<div key={i} className="text-[10px] text-amber-200/90 leading-relaxed mb-1">
|
||||
“{headline}”
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 2: Contradicting Telemetry */}
|
||||
{isContradiction && alert.nearby_outages && alert.nearby_outages.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-red-500/20">
|
||||
<div className="text-[11px] font-mono tracking-widest text-red-400/60 mb-1.5">CONTRADICTING TELEMETRY</div>
|
||||
{alert.nearby_outages.map((outage, i) => (
|
||||
<div key={i} className="flex justify-between items-center text-[10px] mb-1 p-1 rounded bg-red-950/30 border border-red-500/20">
|
||||
<div>
|
||||
<span className="text-red-300 font-semibold">{outage.region || 'Unknown Region'}</span>
|
||||
<span className="text-[#8899aa] ml-1">({outage.distance_km}km away)</span>
|
||||
</div>
|
||||
<span className="text-red-400 font-bold">{outage.severity}% outage</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 3: Market Signals */}
|
||||
{isContradiction && alert.related_markets && alert.related_markets.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-purple-500/20">
|
||||
<div className="text-[11px] font-mono tracking-widest text-purple-400/60 mb-1.5">PREDICTION MARKET SIGNALS</div>
|
||||
{alert.related_markets.map((market, i) => (
|
||||
<div key={i} className="text-[10px] mb-1 p-1 rounded bg-purple-950/30 border border-purple-500/20">
|
||||
<div className="text-purple-300">{market.title}</div>
|
||||
<div className="text-purple-400 font-bold mt-0.5">{(market.probability * 100).toFixed(0)}% probability</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 4: All Drivers (non-contradiction, non-analysis types) */}
|
||||
{!isContradiction && !isAnalysisZone && alert.drivers && alert.drivers.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-[var(--border-primary)]/30">
|
||||
<div className="text-[11px] font-mono tracking-widest text-[var(--text-muted)] mb-1.5">CORRELATED INDICATORS</div>
|
||||
{alert.drivers.map((driver, i) => (
|
||||
<div key={i} className="text-[10px] text-[var(--text-primary)] mb-0.5 flex items-start gap-1">
|
||||
<span className={meta.color}>+</span> {driver}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drivers summary for contradictions */}
|
||||
{isContradiction && alert.drivers && alert.drivers.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-[var(--border-primary)]/30">
|
||||
<div className="text-[11px] font-mono tracking-widest text-[var(--text-muted)] mb-1.5">EVIDENCE CHAIN</div>
|
||||
{alert.drivers.map((driver, i) => (
|
||||
<div key={i} className="text-[10px] text-[var(--text-primary)]/80 mb-0.5 flex items-start gap-1">
|
||||
<span className="text-amber-500">{i + 1}.</span> {driver}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 5: Alternative Explanations */}
|
||||
{isContradiction && alert.alternatives && alert.alternatives.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-[var(--border-primary)]/20">
|
||||
<div className="text-[11px] font-mono tracking-widest text-[var(--text-muted)] mb-1.5">ALTERNATIVE EXPLANATIONS</div>
|
||||
{alert.alternatives.map((alt, i) => (
|
||||
<div key={i} className="text-[9px] text-[#8899aa] mb-0.5 flex items-start gap-1">
|
||||
<span className="text-gray-500">-</span> {alt}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disclaimer */}
|
||||
{isContradiction && (
|
||||
<div className="mt-2 pt-1.5 border-t border-[var(--border-primary)]/10">
|
||||
<div className="text-[10px] text-[#667788] text-center leading-tight">
|
||||
HYPOTHESIS GENERATOR — NOT A VERDICT. This is a signal for further investigation.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import WikiImage from '@/components/WikiImage';
|
||||
import type { MilitaryBase } from '@/types/dashboard';
|
||||
|
||||
export interface OracleIntel {
|
||||
found: boolean;
|
||||
top_headline?: string;
|
||||
oracle_score?: number;
|
||||
tier?: string;
|
||||
avg_sentiment?: number;
|
||||
nearby_count?: number;
|
||||
market?: { title: string; consensus_pct: number | null } | null;
|
||||
}
|
||||
|
||||
export interface MilitaryBasePopupProps {
|
||||
base: MilitaryBase;
|
||||
oracleIntel: OracleIntel | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const BRANCH_LABELS: Record<string, string> = {
|
||||
air_force: 'AIR FORCE',
|
||||
navy: 'NAVY',
|
||||
marines: 'MARINES',
|
||||
army: 'ARMY',
|
||||
gsdf: 'GSDF',
|
||||
msdf: 'MSDF',
|
||||
asdf: 'ASDF',
|
||||
missile: 'MISSILE FORCES',
|
||||
nuclear: 'NUCLEAR FACILITY',
|
||||
};
|
||||
|
||||
const COLOR_MAP: Record<string, string> = {
|
||||
'United States': '#3b82f6',
|
||||
'Guam': '#3b82f6',
|
||||
'Hawaii': '#3b82f6',
|
||||
'BIOT': '#3b82f6',
|
||||
'China': '#ef4444',
|
||||
'Japan': '#e5e7eb',
|
||||
'North Korea': '#92400e',
|
||||
'Russia': '#9ca3af',
|
||||
'Iran': '#f97316',
|
||||
'Taiwan': '#22c55e',
|
||||
'Philippines': '#eab308',
|
||||
'Australia': '#14b8a6',
|
||||
'South Korea': '#a855f7',
|
||||
'United Kingdom': '#6366f1',
|
||||
};
|
||||
|
||||
export function MilitaryBasePopup({ base, oracleIntel, onClose }: MilitaryBasePopupProps) {
|
||||
const accent = COLOR_MAP[base.country] || '#ec4899';
|
||||
const wikiSlug = encodeURIComponent(base.name.replace(/ /g, '_'));
|
||||
const wikiUrl = `https://en.wikipedia.org/wiki/${wikiSlug}`;
|
||||
|
||||
return (
|
||||
<Popup
|
||||
longitude={base.lng}
|
||||
latitude={base.lat}
|
||||
closeButton={false}
|
||||
closeOnClick={false}
|
||||
onClose={onClose}
|
||||
className="threat-popup"
|
||||
maxWidth="340px"
|
||||
>
|
||||
<div
|
||||
className="map-popup bg-[#1a1035] min-w-[220px]"
|
||||
style={{ borderColor: `${accent}66`, color: accent }}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div
|
||||
className="map-popup-title pb-1 flex-1"
|
||||
style={{ color: accent, borderBottom: `1px solid ${accent}33` }}
|
||||
>
|
||||
{base.name}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-2 shrink-0"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="map-popup-row">
|
||||
Operator:{' '}
|
||||
<a
|
||||
href={`https://en.wikipedia.org/wiki/${encodeURIComponent(base.operator.replace(/ /g, '_'))}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-cyan-400 hover:text-cyan-300 underline"
|
||||
>
|
||||
{base.operator}
|
||||
</a>
|
||||
</div>
|
||||
<div className="map-popup-row">
|
||||
Country: <span className="text-white">{base.country}</span>
|
||||
</div>
|
||||
|
||||
{/* Wikipedia image + link — same style as tracked aircraft */}
|
||||
<div className="border-b border-[var(--border-primary)] pb-2 mt-2">
|
||||
<WikiImage
|
||||
wikiUrl={wikiUrl}
|
||||
label={base.name}
|
||||
maxH="max-h-36"
|
||||
accent={`hover:border-[${accent}]`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 text-[12px] tracking-wider" style={{ color: `${accent}99` }}>
|
||||
MILITARY BASE — {BRANCH_LABELS[base.branch] || base.branch.toUpperCase()}
|
||||
</div>
|
||||
|
||||
{oracleIntel?.found && (
|
||||
<div className="mt-2 pt-2 border-t border-cyan-500/20">
|
||||
<div className="text-[11px] font-mono text-cyan-400 tracking-wider mb-1">
|
||||
ORACLE INTEL
|
||||
</div>
|
||||
<div className="text-[11px] font-mono text-cyan-300/80">
|
||||
<span
|
||||
className={
|
||||
oracleIntel.tier === 'CRITICAL'
|
||||
? 'text-red-400'
|
||||
: oracleIntel.tier === 'ELEVATED'
|
||||
? 'text-yellow-400'
|
||||
: 'text-green-400'
|
||||
}
|
||||
>
|
||||
{oracleIntel.tier}
|
||||
</span>
|
||||
{' // '}
|
||||
<span
|
||||
className={
|
||||
oracleIntel.avg_sentiment != null && oracleIntel.avg_sentiment < -0.05
|
||||
? 'text-red-400'
|
||||
: 'text-gray-400'
|
||||
}
|
||||
>
|
||||
{oracleIntel.avg_sentiment != null
|
||||
? `${oracleIntel.avg_sentiment > 0 ? '+' : ''}${oracleIntel.avg_sentiment.toFixed(2)} SENT`
|
||||
: ''}
|
||||
</span>
|
||||
{oracleIntel.market && (
|
||||
<span className="text-purple-400">
|
||||
{' '}
|
||||
// {oracleIntel.market.consensus_pct}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{oracleIntel.top_headline && (
|
||||
<div className="text-[10px] text-white/60 mt-0.5 truncate">
|
||||
{oracleIntel.top_headline}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import ExternalImage from '@/components/ExternalImage';
|
||||
|
||||
export interface Sentinel2Data {
|
||||
found: boolean;
|
||||
fullres_url?: string;
|
||||
thumbnail_url?: string;
|
||||
platform?: string;
|
||||
datetime?: string;
|
||||
cloud_cover?: number;
|
||||
fallback?: boolean;
|
||||
scenes?: Sentinel2Data[];
|
||||
}
|
||||
|
||||
export interface RegionDossierPanelProps {
|
||||
sentinel2: Sentinel2Data;
|
||||
lat: number;
|
||||
lng: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const NAV_BTN: React.CSSProperties = {
|
||||
background: 'rgba(34,197,94,0.2)',
|
||||
border: '1px solid rgba(34,197,94,0.5)',
|
||||
borderRadius: 6,
|
||||
color: '#4ade80',
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
padding: '6px 14px',
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.1em',
|
||||
fontWeight: 'bold',
|
||||
};
|
||||
|
||||
const NAV_BTN_DISABLED: React.CSSProperties = {
|
||||
...NAV_BTN,
|
||||
opacity: 0.3,
|
||||
cursor: 'default',
|
||||
};
|
||||
|
||||
const ACTION_BTN: React.CSSProperties = {
|
||||
background: 'rgba(34,197,94,0.2)',
|
||||
border: '1px solid rgba(34,197,94,0.5)',
|
||||
borderRadius: 6,
|
||||
color: '#4ade80',
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
padding: '6px 16px',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
letterSpacing: '0.15em',
|
||||
fontWeight: 'bold',
|
||||
};
|
||||
|
||||
export function RegionDossierPanel({ sentinel2: s2, lat, lng, onClose }: RegionDossierPanelProps) {
|
||||
const scenes = s2.scenes?.length ? s2.scenes : [s2];
|
||||
const [idx, setIdx] = useState(0);
|
||||
const scene = scenes[idx] || s2;
|
||||
const imgUrl = scene.fullres_url || scene.thumbnail_url;
|
||||
const hasMultiple = scenes.length > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.85)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '80px 40px 80px 40px',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (hasMultiple && e.key === 'ArrowLeft' && idx > 0) setIdx(idx - 1);
|
||||
if (hasMultiple && e.key === 'ArrowRight' && idx < scenes.length - 1) setIdx(idx + 1);
|
||||
}}
|
||||
tabIndex={-1}
|
||||
ref={(el) => el?.focus()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.95)',
|
||||
border: '1px solid rgba(34,197,94,0.5)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
maxWidth: 'calc(100vw - 120px)',
|
||||
maxHeight: 'calc(100vh - 160px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 0 60px rgba(34,197,94,0.3)',
|
||||
}}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 16px',
|
||||
background: 'rgba(20,83,45,0.4)',
|
||||
borderBottom: '1px solid rgba(34,197,94,0.3)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: '#4ade80',
|
||||
animation: 'pulse 2s infinite',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: '#4ade80',
|
||||
fontFamily: 'monospace',
|
||||
letterSpacing: '0.2em',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
SENTINEL-2 IMAGERY
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'rgba(134,239,172,0.6)',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
{lat.toFixed(4)}, {lng.toFixed(4)}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'rgba(239,68,68,0.2)',
|
||||
border: '1px solid rgba(239,68,68,0.4)',
|
||||
borderRadius: 6,
|
||||
color: '#ef4444',
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
padding: '4px 10px',
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.1em',
|
||||
}}
|
||||
>
|
||||
✕ CLOSE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scene.found ? (
|
||||
<>
|
||||
{/* Metadata row with scene navigation */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 16px',
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
borderBottom: '1px solid rgba(20,83,45,0.4)',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#86efac' }}>{scene.platform}</span>
|
||||
|
||||
{hasMultiple ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<button
|
||||
onClick={() => idx > 0 && setIdx(idx - 1)}
|
||||
disabled={idx === 0}
|
||||
style={idx === 0 ? NAV_BTN_DISABLED : NAV_BTN}
|
||||
>
|
||||
← PREV
|
||||
</button>
|
||||
<span style={{ color: '#4ade80', fontWeight: 'bold', minWidth: 120, textAlign: 'center' }}>
|
||||
{scene.datetime?.slice(0, 10) || 'UNKNOWN DATE'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => idx < scenes.length - 1 && setIdx(idx + 1)}
|
||||
disabled={idx === scenes.length - 1}
|
||||
style={idx === scenes.length - 1 ? NAV_BTN_DISABLED : NAV_BTN}
|
||||
>
|
||||
NEXT →
|
||||
</button>
|
||||
<span style={{ color: 'rgba(134,239,172,0.5)', fontSize: 10 }}>
|
||||
{idx + 1}/{scenes.length}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: '#4ade80', fontWeight: 'bold' }}>
|
||||
{scene.datetime?.slice(0, 10) ||
|
||||
(scene.fallback ? 'DATE UNAVAILABLE' : 'UNKNOWN DATE')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span style={{ color: '#86efac' }}>
|
||||
{scene.cloud_cover != null
|
||||
? `${scene.cloud_cover?.toFixed(0)}% cloud`
|
||||
: scene.fallback
|
||||
? 'fallback imagery'
|
||||
: 'cloud unknown'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
{imgUrl ? (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: 400,
|
||||
}}
|
||||
>
|
||||
<ExternalImage
|
||||
src={imgUrl}
|
||||
alt="Sentinel-2 scene"
|
||||
width={1024}
|
||||
height={1024}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 'calc(100vh - 260px)',
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
padding: '40px 16px',
|
||||
fontSize: 12,
|
||||
color: 'rgba(134,239,172,0.5)',
|
||||
fontFamily: 'monospace',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Scene found — no preview available
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
{imgUrl && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
padding: '10px 16px',
|
||||
background: 'rgba(20,83,45,0.3)',
|
||||
borderTop: '1px solid rgba(34,197,94,0.2)',
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={imgUrl}
|
||||
download={`sentinel2_${lat.toFixed(4)}_${lng.toFixed(4)}_${scene.datetime?.slice(0, 10) || 'unknown'}.jpg`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={ACTION_BTN}
|
||||
>
|
||||
⬇ DOWNLOAD
|
||||
</a>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const resp = await fetch(imgUrl);
|
||||
const blob = await resp.blob();
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ [blob.type]: blob }),
|
||||
]);
|
||||
} catch {
|
||||
await navigator.clipboard.writeText(imgUrl);
|
||||
}
|
||||
}}
|
||||
style={{ ...ACTION_BTN, background: 'rgba(34,197,94,0.15)', borderColor: 'rgba(34,197,94,0.4)' }}
|
||||
>
|
||||
📋 COPY
|
||||
</button>
|
||||
<a
|
||||
href={imgUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ ...ACTION_BTN, color: '#10b981', background: 'rgba(16,185,129,0.15)', borderColor: 'rgba(16,185,129,0.4)' }}
|
||||
>
|
||||
↗ OPEN FULL RES
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
padding: '40px 16px',
|
||||
fontSize: 12,
|
||||
color: 'rgba(134,239,172,0.5)',
|
||||
fontFamily: 'monospace',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
No clear imagery in last 30 days
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import WikiImage from '@/components/WikiImage';
|
||||
import type { Satellite, SatManeuverAlert } from '@/types/dashboard';
|
||||
|
||||
export interface SatellitePopupProps {
|
||||
sat: Satellite;
|
||||
maneuverAlert?: SatManeuverAlert;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MISSION_LABELS: Record<string, string> = {
|
||||
military_recon: '🔴 MILITARY RECON',
|
||||
military_sar: '🔴 MILITARY SAR',
|
||||
military_comms: '🔴 MILITARY COMMS',
|
||||
sar: '🔷 SAR IMAGING',
|
||||
sigint: '🟠 SIGINT / ELINT',
|
||||
navigation: '🔵 NAVIGATION',
|
||||
early_warning: '🟣 EARLY WARNING',
|
||||
commercial_imaging: '🟢 COMMERCIAL IMAGING',
|
||||
space_station: '🏠 SPACE STATION',
|
||||
starlink: '🌐 STARLINK',
|
||||
constellation: '🌐 CONSTELLATION',
|
||||
communication: '📡 COMMUNICATION',
|
||||
};
|
||||
|
||||
export function SatellitePopup({ sat, maneuverAlert, onClose }: SatellitePopupProps) {
|
||||
const isISS = sat.mission === 'space_station' && sat.name?.includes('ISS');
|
||||
|
||||
return (
|
||||
<Popup
|
||||
longitude={sat.lng}
|
||||
latitude={sat.lat}
|
||||
closeButton={false}
|
||||
closeOnClick={false}
|
||||
onClose={onClose}
|
||||
anchor="bottom"
|
||||
offset={isISS ? 20 : 12}
|
||||
maxWidth={isISS ? '320px' : '260px'}
|
||||
>
|
||||
<div className={`map-popup ${isISS ? 'border border-yellow-500/50' : 'border border-cyan-500/30'}`}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className={`map-popup-title ${isISS ? 'text-[#ffdd00]' : 'text-[#00c8ff]'}`}>
|
||||
🛰️ {sat.name}
|
||||
</div>
|
||||
{isISS && (
|
||||
<span className="text-[11px] font-mono tracking-widest text-yellow-500/80 border border-yellow-500/30 px-1 rounded">LIVE</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="map-popup-row text-[#8899aa]">
|
||||
NORAD ID: <span className="text-white">{sat.id}</span>
|
||||
</div>
|
||||
{sat.sat_type && (
|
||||
<div className="map-popup-row">
|
||||
Type: <span className="text-[#ffcc00]">{sat.sat_type}</span>
|
||||
</div>
|
||||
)}
|
||||
{sat.country && (
|
||||
<div className="map-popup-row">
|
||||
Country: <span className="text-white">{sat.country}</span>
|
||||
</div>
|
||||
)}
|
||||
{sat.mission && (
|
||||
<div className="map-popup-row font-semibold">
|
||||
{MISSION_LABELS[sat.mission] || `⚪ ${sat.mission.toUpperCase()}`}
|
||||
</div>
|
||||
)}
|
||||
<div className="map-popup-row">
|
||||
Altitude:{' '}
|
||||
<span className="text-[#44ff88]">{sat.alt_km?.toLocaleString()} km</span>
|
||||
</div>
|
||||
{maneuverAlert && (
|
||||
<div className="mt-1.5 p-1.5 rounded bg-red-900/30 border border-red-500/40">
|
||||
<div className="text-[11px] font-mono tracking-widest text-red-400 mb-0.5">MANEUVER DETECTED</div>
|
||||
{maneuverAlert.reasons.map((r, i) => (
|
||||
<div key={i} className="text-[9px] text-red-300/80 font-mono">{r}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isISS && (
|
||||
<div className="map-popup-row text-[#8899aa]">
|
||||
Speed: <span className="text-white">{sat.speed_knots ? `${Math.round(sat.speed_knots * 1.852).toLocaleString()} km/h` : '~28,000 km/h'}</span>
|
||||
</div>
|
||||
)}
|
||||
{isISS && (
|
||||
<div className="mt-2 pt-2 border-t border-yellow-500/20">
|
||||
<div className="text-[11px] font-mono tracking-widest text-yellow-500/60 mb-1.5">NASA EHDC LIVE FEED</div>
|
||||
<div className="relative w-full rounded overflow-hidden bg-black/60" style={{ paddingBottom: '56.25%' }}>
|
||||
<iframe
|
||||
src="https://video.ibm.com/embed/17074538?autoplay=0&html5ui"
|
||||
className="absolute inset-0 w-full h-full"
|
||||
allow="autoplay"
|
||||
allowFullScreen
|
||||
style={{ border: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] text-[#8899aa] mt-1 text-center">
|
||||
Earth view from ISS external cameras • Dark = nightside pass
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sat.wiki && !isISS && (
|
||||
<div className="mt-2 border-t border-[var(--border-primary)]/50 pt-2">
|
||||
<WikiImage
|
||||
wikiUrl={sat.wiki}
|
||||
label={sat.sat_type || sat.name}
|
||||
maxH="max-h-28"
|
||||
accent="hover:border-cyan-500/50"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isISS && sat.wiki && (
|
||||
<div className="mt-1.5">
|
||||
<a href={sat.wiki} target="_blank" rel="noopener noreferrer"
|
||||
className="block text-center px-2 py-1 rounded bg-yellow-900/30 border border-yellow-500/20
|
||||
hover:bg-yellow-800/40 hover:border-yellow-400/40 text-yellow-300 text-[9px] font-mono tracking-widest">
|
||||
WIKIPEDIA ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import type { Ship } from '@/types/dashboard';
|
||||
|
||||
export interface ShipPopupProps {
|
||||
ship: Ship;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ShipPopup({ ship, longitude, latitude, onClose }: ShipPopupProps) {
|
||||
return (
|
||||
<Popup
|
||||
longitude={longitude}
|
||||
latitude={latitude}
|
||||
closeButton={false}
|
||||
closeOnClick={false}
|
||||
onClose={onClose}
|
||||
anchor="bottom"
|
||||
offset={12}
|
||||
>
|
||||
<div
|
||||
className="map-popup"
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
borderColor: ship.yacht_alert
|
||||
? 'rgba(255,105,180,0.5)'
|
||||
: ship.type === 'carrier'
|
||||
? 'rgba(255,170,0,0.5)'
|
||||
: 'rgba(59,130,246,0.4)',
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<div
|
||||
className="map-popup-title"
|
||||
style={{
|
||||
color: ship.yacht_alert
|
||||
? '#FF69B4'
|
||||
: ship.type === 'carrier'
|
||||
? '#ffaa00'
|
||||
: '#3b82f6',
|
||||
}}
|
||||
>
|
||||
{ship.name || 'UNKNOWN VESSEL'}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{ship.estimated && (
|
||||
<div className="map-popup-subtitle text-[#ff6644] border-b border-[#ff664450] pb-1">
|
||||
ESTIMATED POSITION — {ship.source || 'OSINT DERIVED'}
|
||||
</div>
|
||||
)}
|
||||
{ship.type && (
|
||||
<div className="map-popup-row">
|
||||
Type:{' '}
|
||||
<span className="text-white capitalize">{ship.type.replace('_', ' ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{ship.mmsi && (
|
||||
<div className="map-popup-row">
|
||||
MMSI: <span className="text-[#888]">{ship.mmsi}</span>
|
||||
</div>
|
||||
)}
|
||||
{ship.imo && (
|
||||
<div className="map-popup-row">
|
||||
IMO: <span className="text-[#888]">{ship.imo}</span>
|
||||
</div>
|
||||
)}
|
||||
{ship.callsign && (
|
||||
<div className="map-popup-row">
|
||||
Callsign: <span className="text-[#00e5ff]">{ship.callsign}</span>
|
||||
</div>
|
||||
)}
|
||||
{ship.country && (
|
||||
<div className="map-popup-row">
|
||||
Flag: <span className="text-white">{ship.country}</span>
|
||||
</div>
|
||||
)}
|
||||
{ship.destination && (
|
||||
<div className="map-popup-row">
|
||||
Destination: <span className="text-[#44ff88]">{ship.destination}</span>
|
||||
</div>
|
||||
)}
|
||||
{typeof ship.sog === 'number' && ship.sog > 0 && (
|
||||
<div className="map-popup-row">
|
||||
Speed: <span className="text-[#00e5ff]">{ship.sog.toFixed(1)} kn</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="map-popup-row">
|
||||
Heading:{' '}
|
||||
<span style={{ color: ship.heading != null ? '#888' : '#ff6644' }}>
|
||||
{ship.heading != null ? `${Math.round(ship.heading)}°` : 'UNKNOWN'}
|
||||
</span>
|
||||
</div>
|
||||
{ship.type === 'carrier' && ship.source && (
|
||||
<div className="mt-1.5 p-[5px_7px] bg-[rgba(255,170,0,0.08)] border border-[rgba(255,170,0,0.3)] rounded text-[9px] tracking-wide">
|
||||
<div className="text-[#ffaa00] mb-0.5">
|
||||
SOURCE:{' '}
|
||||
{ship.source_url ? (
|
||||
<a
|
||||
href={ship.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#00e5ff] underline"
|
||||
>
|
||||
{ship.source}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-white">{ship.source}</span>
|
||||
)}
|
||||
</div>
|
||||
{ship.last_osint_update && (
|
||||
<div className="text-[#888]">
|
||||
LAST OSINT UPDATE:{' '}
|
||||
{new Date(ship.last_osint_update).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{ship.desc && (
|
||||
<div className="text-[#aaa] mt-0.5 text-[11px] leading-tight">
|
||||
{ship.desc}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{ship.type !== 'carrier' && ship.last_osint_update && (
|
||||
<div className="map-popup-row">
|
||||
Last OSINT Update:{' '}
|
||||
<span className="text-[#888]">
|
||||
{new Date(ship.last_osint_update).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{ship.yacht_alert && (
|
||||
<div className="mt-1.5 p-[5px_7px] bg-[rgba(255,105,180,0.08)] border border-[rgba(255,105,180,0.3)] rounded text-[9px] tracking-wide">
|
||||
<div className="text-[#FF69B4] font-bold mb-0.5">TRACKED YACHT</div>
|
||||
<div>
|
||||
Owner: <span className="text-white">{ship.yacht_owner}</span>
|
||||
</div>
|
||||
{ship.yacht_builder && (
|
||||
<div>
|
||||
Builder: <span className="text-[#888]">{ship.yacht_builder}</span>
|
||||
</div>
|
||||
)}
|
||||
{(ship.yacht_length ?? 0) > 0 && (
|
||||
<div>
|
||||
Length: <span className="text-[#888]">{ship.yacht_length}m</span>
|
||||
</div>
|
||||
)}
|
||||
{(ship.yacht_year ?? 0) > 0 && (
|
||||
<div>
|
||||
Year: <span className="text-[#888]">{ship.yacht_year}</span>
|
||||
</div>
|
||||
)}
|
||||
{ship.yacht_category && (
|
||||
<div>
|
||||
Category: <span className="text-[#FF69B4]">{ship.yacht_category}</span>
|
||||
</div>
|
||||
)}
|
||||
{ship.yacht_link && (
|
||||
<a
|
||||
href={ship.yacht_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#00e5ff] underline"
|
||||
>
|
||||
Wikipedia
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import { AlertTriangle, Radio, Play } from 'lucide-react';
|
||||
import { SigintSendForm, MeshtasticChannelFeed } from '@/components/map/panels/SigintPanels';
|
||||
import type { KiwiSDR, SigintSignal } from '@/types/dashboard';
|
||||
|
||||
type GeoExtras = {
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
lon?: number;
|
||||
geometry?: { coordinates?: [number, number] };
|
||||
};
|
||||
|
||||
export type SigintData = Partial<SigintSignal> & GeoExtras;
|
||||
|
||||
export interface SigintPopupProps {
|
||||
data: SigintData;
|
||||
lat: number;
|
||||
lng: number;
|
||||
kiwisdrs: KiwiSDR[];
|
||||
setTrackedSdr?: (sdr: {
|
||||
lat: number;
|
||||
lon: number;
|
||||
name: string;
|
||||
url?: string;
|
||||
users?: number;
|
||||
users_max?: number;
|
||||
bands?: string;
|
||||
antenna?: string;
|
||||
location?: string;
|
||||
}) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
aprs: '#ff69b4',
|
||||
meshtastic: '#22c55e',
|
||||
js8call: '#ff69b4',
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
aprs: 'APRS-IS',
|
||||
meshtastic: 'MESHTASTIC',
|
||||
js8call: 'JS8CALL',
|
||||
};
|
||||
|
||||
function computePosAge(d: SigintData): string | null {
|
||||
const ts = d.position_updated_at || d.timestamp;
|
||||
if (!ts) return null;
|
||||
try {
|
||||
const then = new Date(ts).getTime();
|
||||
const diffMs = Date.now() - then;
|
||||
if (diffMs < 0 || isNaN(diffMs)) return null;
|
||||
const mins = Math.floor(diffMs / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findNearestSdr(
|
||||
src: string,
|
||||
lat: number,
|
||||
lng: number,
|
||||
sdrs: KiwiSDR[],
|
||||
): KiwiSDR | null {
|
||||
if (src === 'meshtastic') return null;
|
||||
if (!sdrs || !sdrs.length) return null;
|
||||
let best: KiwiSDR | null = null;
|
||||
let bestDist = Infinity;
|
||||
for (const sdr of sdrs) {
|
||||
const slat = sdr.lat;
|
||||
const slng = sdr.lon;
|
||||
if (slat == null || slng == null || !sdr.url) continue;
|
||||
const dist = Math.sqrt((lat - slat) ** 2 + (lng - slng) ** 2);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = sdr;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function SigintPopup({ data: d, lat, lng, kiwisdrs, setTrackedSdr, onClose }: SigintPopupProps) {
|
||||
const src = d.source || 'unknown';
|
||||
const isEmergency = d.emergency === true;
|
||||
const color = isEmergency ? '#ef4444' : SOURCE_COLORS[src] || '#94a3b8';
|
||||
const stationType = d.station_type || 'Station';
|
||||
const status = d.status || d.comment || '';
|
||||
const isApiNode = d.from_api === true;
|
||||
const posAge = computePosAge(d);
|
||||
const nearestSdr = findNearestSdr(src, lat, lng, kiwisdrs);
|
||||
|
||||
return (
|
||||
<Popup
|
||||
longitude={lng}
|
||||
latitude={lat}
|
||||
closeButton={false}
|
||||
closeOnClick={false}
|
||||
onClose={onClose}
|
||||
anchor="bottom"
|
||||
offset={12}
|
||||
>
|
||||
<div
|
||||
className="map-popup"
|
||||
style={{ borderWidth: 1, borderStyle: 'solid', borderColor: `${color}66` }}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<div className="map-popup-title" style={{ color }}>
|
||||
{isEmergency && (
|
||||
<AlertTriangle
|
||||
size={12}
|
||||
className="inline mr-1 animate-pulse"
|
||||
style={{ color: '#ef4444' }}
|
||||
/>
|
||||
)}
|
||||
{(d.callsign || 'UNKNOWN').toUpperCase()}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="map-popup-subtitle border-b pb-1 flex items-center gap-1.5 flex-wrap"
|
||||
style={{ color: `${color}99`, borderColor: `${color}30` }}
|
||||
>
|
||||
<Radio size={10} />
|
||||
<span
|
||||
className="font-mono text-[12px] px-1.5 py-0.5 rounded"
|
||||
style={{ backgroundColor: `${color}20`, color }}
|
||||
>
|
||||
{SOURCE_LABELS[src] || src.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-[var(--text-muted)]">{stationType}</span>
|
||||
{isEmergency && (
|
||||
<span className="font-mono text-[11px] px-1.5 py-0.5 rounded bg-red-900/60 text-red-400 animate-pulse tracking-wider">
|
||||
EMERGENCY
|
||||
</span>
|
||||
)}
|
||||
{src === 'meshtastic' && d.channel && (
|
||||
<span className="font-mono text-[11px] px-1.5 py-0.5 rounded bg-green-900/50 text-green-300 border border-green-500/30">
|
||||
{d.channel}
|
||||
</span>
|
||||
)}
|
||||
{src === 'meshtastic' && d.region && (
|
||||
<span className="font-mono text-[11px] px-1.5 py-0.5 rounded bg-slate-800/60 text-slate-300 border border-slate-500/30">
|
||||
{d.region}
|
||||
</span>
|
||||
)}
|
||||
{isApiNode && (
|
||||
<span className="font-mono text-[11px] px-1.5 py-0.5 rounded bg-blue-900/40 text-blue-300 border border-blue-500/30">
|
||||
MAP API
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Long name + hardware (API nodes) */}
|
||||
{src === 'meshtastic' && (d.long_name || d.hardware) && (
|
||||
<div className="map-popup-row mt-0.5 flex items-center gap-1.5 flex-wrap">
|
||||
{d.long_name && <span className="text-[13px] text-white">{d.long_name}</span>}
|
||||
{d.hardware && (
|
||||
<span className="text-[11px] text-slate-400">({d.hardware})</span>
|
||||
)}
|
||||
{d.role && d.role !== 'CLIENT' && (
|
||||
<span className="font-mono text-[11px] px-1 py-0.5 rounded bg-amber-900/40 text-amber-300 border border-amber-500/30">
|
||||
{d.role}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Position age */}
|
||||
{posAge && (
|
||||
<div className="map-popup-row mt-0.5">
|
||||
<span className="text-[12px] text-[var(--text-muted)]">
|
||||
Last heard: <span className="text-slate-300">{posAge}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
{status && (
|
||||
<div className="map-popup-row mt-1">
|
||||
<span
|
||||
className={`text-[13px] ${isEmergency ? 'text-red-300 font-bold' : 'text-white'}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key telemetry */}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 mt-1">
|
||||
{d.frequency && (
|
||||
<div className="map-popup-row">
|
||||
Freq: <span className="text-cyan-400">{d.frequency}</span>
|
||||
</div>
|
||||
)}
|
||||
{(d.altitude_ft ?? 0) > 0 && (
|
||||
<div className="map-popup-row">
|
||||
Alt:{' '}
|
||||
<span className="text-white">
|
||||
{Number(d.altitude_ft).toLocaleString()} ft
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(d.speed_knots ?? 0) > 0 && (
|
||||
<div className="map-popup-row">
|
||||
Speed:{' '}
|
||||
<span className="text-white">
|
||||
{d.speed_knots} kts / {d.course || 0}°
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(d.power_watts ?? 0) > 0 && (
|
||||
<div className="map-popup-row">
|
||||
TX Power: <span className="text-amber-400">{d.power_watts}W</span>
|
||||
</div>
|
||||
)}
|
||||
{(d.battery_v ?? 0) > 0 && (
|
||||
<div className="map-popup-row">
|
||||
Battery: <span className="text-white">{d.battery_v}V</span>
|
||||
</div>
|
||||
)}
|
||||
{!d.battery_v && d.battery_level != null && d.battery_level <= 100 && (
|
||||
<div className="map-popup-row">
|
||||
Battery: <span className="text-white">{d.battery_level}%</span>
|
||||
</div>
|
||||
)}
|
||||
{d.snr != null && (
|
||||
<div className="map-popup-row">
|
||||
SNR: <span className="text-white">{d.snr} dB</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons: Tune In via nearest KiwiSDR */}
|
||||
<div className="flex items-center gap-2 mt-2 pt-1.5 border-t border-[var(--border-primary)]/30">
|
||||
{nearestSdr?.url && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (setTrackedSdr) {
|
||||
setTrackedSdr({
|
||||
lat: nearestSdr.lat,
|
||||
lon: nearestSdr.lon,
|
||||
name: nearestSdr.name,
|
||||
url: nearestSdr.url,
|
||||
users: nearestSdr.users,
|
||||
users_max: nearestSdr.users_max,
|
||||
bands: nearestSdr.bands,
|
||||
antenna: nearestSdr.antenna,
|
||||
location: nearestSdr.location,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
className="flex-1 text-center px-2 py-1.5 rounded bg-cyan-950/40 border border-cyan-500/30 hover:bg-cyan-900/60 hover:border-cyan-400 text-cyan-400 text-[12px] font-mono tracking-widest transition-colors flex justify-center items-center gap-1.5"
|
||||
title={`Listen via ${nearestSdr.name}`}
|
||||
>
|
||||
<Play size={10} className="fill-cyan-400/20" /> TUNE IN
|
||||
</button>
|
||||
)}
|
||||
<span className="text-[#666] text-[12px]">
|
||||
{Number(lat).toFixed(4)}, {Number(lng).toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
{nearestSdr && (
|
||||
<div className="text-[11px] text-[#555] mt-0.5">
|
||||
via {nearestSdr.name} ({nearestSdr.location || 'SDR'})
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meshtastic channel feed */}
|
||||
{src === 'meshtastic' && d.region && (
|
||||
<MeshtasticChannelFeed region={d.region} channel={d.channel || 'LongFast'} />
|
||||
)}
|
||||
|
||||
{/* Send Message */}
|
||||
{src === 'meshtastic' && (
|
||||
<SigintSendForm
|
||||
destination={
|
||||
typeof d.callsign === 'string' && /^![0-9a-f]{8}$/i.test(d.callsign)
|
||||
? d.callsign
|
||||
: d.channel || 'LongFast'
|
||||
}
|
||||
source={src}
|
||||
region={d.region}
|
||||
channel={d.channel || 'LongFast'}
|
||||
/>
|
||||
)}
|
||||
{src === 'aprs' && (
|
||||
<div className="mt-2 pt-1.5 border-t border-[var(--border-primary)]/30 text-[11px] text-[#555] italic">
|
||||
APRS is receive-only — transmitting requires a ham radio license
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import type { WastewaterPlant } from '@/types/dashboard';
|
||||
|
||||
export interface WastewaterPopupProps {
|
||||
plant: WastewaterPlant;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ACTIVITY_COLORS: Record<string, string> = {
|
||||
'very high': 'text-red-400',
|
||||
high: 'text-red-400',
|
||||
'above normal': 'text-amber-400',
|
||||
normal: 'text-green-400',
|
||||
'below normal': 'text-blue-400',
|
||||
low: 'text-blue-300',
|
||||
'not calculated': 'text-gray-500',
|
||||
};
|
||||
|
||||
export function WastewaterPopup({ plant, onClose }: WastewaterPopupProps) {
|
||||
const hasAlerts = plant.alert_count > 0;
|
||||
const borderColor = hasAlerts ? 'border-red-500/50' : 'border-cyan-500/40';
|
||||
|
||||
return (
|
||||
<Popup
|
||||
longitude={plant.lng}
|
||||
latitude={plant.lat}
|
||||
closeButton={false}
|
||||
closeOnClick={false}
|
||||
onClose={onClose}
|
||||
anchor="bottom"
|
||||
offset={12}
|
||||
maxWidth="320px"
|
||||
>
|
||||
<div className={`map-popup border ${borderColor}`}>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className={`map-popup-title ${hasAlerts ? 'text-red-400' : 'text-cyan-400'}`}>
|
||||
{hasAlerts ? '!! PATHOGEN ALERT !!' : 'WASTEWATER MONITOR'}
|
||||
</div>
|
||||
{plant.alert_count > 0 && (
|
||||
<span className="text-[11px] font-mono tracking-widest px-1.5 py-0.5 rounded border bg-red-900/50 border-red-500/40 text-red-300">
|
||||
{plant.alert_count} ALERT{plant.alert_count > 1 ? 'S' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Site info */}
|
||||
<div className="map-popup-row text-[#8899aa] mb-1">
|
||||
SITE: <span className="text-white">{plant.name || plant.site_name}</span>
|
||||
</div>
|
||||
{plant.city && (
|
||||
<div className="map-popup-row text-[#8899aa] mb-1">
|
||||
LOCATION: <span className="text-white">{plant.city}, {plant.state}</span>
|
||||
</div>
|
||||
)}
|
||||
{plant.population && (
|
||||
<div className="map-popup-row text-[#8899aa] mb-1">
|
||||
POP SERVED: <span className="text-white">{plant.population.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{plant.collection_date && (
|
||||
<div className="map-popup-row text-[#8899aa] mb-2">
|
||||
SAMPLED: <span className="text-white">{plant.collection_date}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pathogen levels */}
|
||||
{plant.pathogens && plant.pathogens.length > 0 ? (
|
||||
<div className="mt-2 pt-2 border-t border-cyan-500/20">
|
||||
<div className="text-[11px] font-mono tracking-widest text-cyan-400/60 mb-1.5">PATHOGEN DETECTIONS</div>
|
||||
{plant.pathogens.map((p, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex justify-between items-center text-[10px] mb-1 p-1 rounded border ${
|
||||
p.alert ? 'bg-red-950/30 border-red-500/20' : 'bg-gray-900/30 border-gray-700/20'
|
||||
}`}
|
||||
>
|
||||
<span className={p.alert ? 'text-red-300 font-semibold' : 'text-gray-300'}>
|
||||
{p.name}
|
||||
</span>
|
||||
<span className={`font-mono ${ACTIVITY_COLORS[p.activity.toLowerCase()] || 'text-gray-400'}`}>
|
||||
{p.activity.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 pt-2 border-t border-gray-600/20">
|
||||
<div className="text-[9px] text-gray-500 text-center">No recent pathogen data available</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source attribution */}
|
||||
<div className="mt-2 pt-1.5 border-t border-[var(--border-primary)]/10">
|
||||
<div className="text-[10px] text-[#667788] text-center leading-tight">
|
||||
SOURCE: WastewaterSCAN (Stanford / Emory)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -160,7 +160,7 @@ function CongressTab({ trades }: { trades: CongressTrade[] }) {
|
||||
</div>
|
||||
</div>
|
||||
{t.asset_name && t.asset_name !== t.ticker && (
|
||||
<div className="text-[8px] text-[var(--text-muted)]/70 truncate mt-0.5">{t.asset_name}</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)]/70 truncate mt-0.5">{t.asset_name}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -200,7 +200,7 @@ function InsiderTab({ transactions }: { transactions: InsiderTransaction[] }) {
|
||||
</div>
|
||||
</div>
|
||||
{t.filing_date && (
|
||||
<div className="text-[8px] text-[var(--text-muted)]/70 mt-0.5">{t.filing_date}</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)]/70 mt-0.5">{t.filing_date}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -281,7 +281,7 @@ const MarketsPanel = React.memo(function MarketsPanel({ data, focused, onFocusCh
|
||||
GLOBAL MARKETS
|
||||
</span>
|
||||
{hasFinnhub && (
|
||||
<span className="text-[8px] text-green-500 bg-green-900/30 px-1 rounded">FINNHUB</span>
|
||||
<span className="text-[11px] text-green-500 bg-green-900/30 px-1 rounded">FINNHUB</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
|
||||
@@ -340,7 +340,7 @@ const MarketsPanel = React.memo(function MarketsPanel({ data, focused, onFocusCh
|
||||
|
||||
{/* Attribution */}
|
||||
<div className="px-3 pb-2">
|
||||
<p className="text-[8px] text-[var(--text-muted)]/60 text-center">
|
||||
<p className="text-[11px] text-[var(--text-muted)]/60 text-center">
|
||||
Data from Finnhub
|
||||
</p>
|
||||
</div>
|
||||
@@ -363,7 +363,7 @@ const MarketsPanel = React.memo(function MarketsPanel({ data, focused, onFocusCh
|
||||
href="https://finnhub.io/register"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-[8px] text-cyan-400 hover:text-cyan-300 transition-colors"
|
||||
className="flex items-center gap-1 text-[11px] text-cyan-400 hover:text-cyan-300 transition-colors"
|
||||
>
|
||||
Free API Key <ExternalLink size={8} />
|
||||
</a>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
export function RepBadge({ rep }: { rep: number }) {
|
||||
const color =
|
||||
rep >= 50
|
||||
? 'text-yellow-400'
|
||||
: rep >= 10
|
||||
? 'text-cyan-400'
|
||||
: rep > 0
|
||||
? 'text-cyan-600'
|
||||
: rep < 0
|
||||
? 'text-red-400'
|
||||
: 'text-gray-600';
|
||||
return (
|
||||
<span className={`text-[13px] font-mono font-bold ${color} shrink-0`}>
|
||||
{rep >= 0 ? '+' : ''}
|
||||
{rep}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
loadIdentityBoundSensitiveValue,
|
||||
persistIdentityBoundSensitiveValue,
|
||||
} from '@/lib/identityBoundSensitiveStorage';
|
||||
import {
|
||||
decryptSenderSealPayloadLocally,
|
||||
getNodeIdentity,
|
||||
unwrapSenderSealPayload,
|
||||
verifyNodeIdBindingFromPublicKey,
|
||||
verifyRawSignature,
|
||||
} from '@/mesh/meshIdentity';
|
||||
import type { Contact } from '@/mesh/meshIdentity';
|
||||
import {
|
||||
isWormholeReady,
|
||||
openWormholeSenderSeal,
|
||||
} from '@/mesh/wormholeIdentityClient';
|
||||
import {
|
||||
recoverSenderSealWithFallback,
|
||||
} from '@/mesh/requestSenderRecovery';
|
||||
import { allDmPeerIds, mergeAliasHistory } from '@/mesh/meshDmConsent';
|
||||
import type { AccessRequest } from './types';
|
||||
|
||||
// ─── Local storage keys ─────────────────────────────────────────────────────
|
||||
|
||||
const ACCESS_REQUESTS_KEY = 'sb_dm_access_requests';
|
||||
const PENDING_SENT_KEY = 'sb_dm_pending_sent';
|
||||
const MUTED_KEY = 'sb_mesh_muted';
|
||||
const GEO_HINT_KEY = 'sb_dm_geo_hint';
|
||||
const ACCESS_REQ_WRAP_INFO = 'SB-ACCESS-REQUESTS-STORAGE-V1';
|
||||
const PENDING_WRAP_INFO = 'SB-PENDING-CONTACTS-STORAGE-V1';
|
||||
const MUTED_WRAP_INFO = 'SB-MUTED-LIST-V1';
|
||||
|
||||
export const DECOY_KEY = 'sb_dm_decoy';
|
||||
|
||||
// ─── Scoped state helpers ───────────────────────────────────────────────────
|
||||
|
||||
export function scopedDmStateKey(base: string, nodeId?: string): string {
|
||||
const resolved = String(nodeId || getNodeIdentity()?.nodeId || 'global').trim() || 'global';
|
||||
return `${base}:${resolved}`;
|
||||
}
|
||||
|
||||
export async function getAccessRequests(nodeId?: string): Promise<AccessRequest[]> {
|
||||
const storageKey = scopedDmStateKey(ACCESS_REQUESTS_KEY, nodeId);
|
||||
try {
|
||||
const requests = await loadIdentityBoundSensitiveValue<AccessRequest[]>(
|
||||
storageKey,
|
||||
ACCESS_REQ_WRAP_INFO,
|
||||
[],
|
||||
);
|
||||
const normalized = Array.isArray(requests) ? requests : [];
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
console.warn('[mesh] failed to read encrypted access requests', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function setAccessRequests(reqs: AccessRequest[], nodeId?: string) {
|
||||
const storageKey = scopedDmStateKey(ACCESS_REQUESTS_KEY, nodeId);
|
||||
void (async () => {
|
||||
try {
|
||||
await persistIdentityBoundSensitiveValue(storageKey, ACCESS_REQ_WRAP_INFO, reqs);
|
||||
} catch (error) {
|
||||
console.warn('[mesh] failed to persist encrypted access requests', error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export async function getPendingSent(nodeId?: string): Promise<string[]> {
|
||||
const storageKey = scopedDmStateKey(PENDING_SENT_KEY, nodeId);
|
||||
try {
|
||||
const pending = await loadIdentityBoundSensitiveValue<string[]>(storageKey, PENDING_WRAP_INFO, []);
|
||||
const normalized = Array.isArray(pending) ? pending : [];
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
console.warn('[mesh] failed to read encrypted pending contacts', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function setPendingSent(ids: string[], nodeId?: string) {
|
||||
const storageKey = scopedDmStateKey(PENDING_SENT_KEY, nodeId);
|
||||
void (async () => {
|
||||
try {
|
||||
await persistIdentityBoundSensitiveValue(storageKey, PENDING_WRAP_INFO, ids);
|
||||
} catch (error) {
|
||||
console.warn('[mesh] failed to persist encrypted pending contacts', error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export function getGeoHintEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(GEO_HINT_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setGeoHintEnabled(value: boolean) {
|
||||
localStorage.setItem(GEO_HINT_KEY, value ? 'true' : 'false');
|
||||
}
|
||||
|
||||
export function getDecoyEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(DECOY_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setDecoyEnabled(value: boolean) {
|
||||
localStorage.setItem(DECOY_KEY, value ? 'true' : 'false');
|
||||
}
|
||||
|
||||
export async function getMutedList(nodeId?: string): Promise<string[]> {
|
||||
const storageKey = scopedDmStateKey(MUTED_KEY, nodeId);
|
||||
try {
|
||||
const muted = await loadIdentityBoundSensitiveValue<string[]>(
|
||||
storageKey,
|
||||
MUTED_WRAP_INFO,
|
||||
[],
|
||||
{ legacyKey: MUTED_KEY },
|
||||
);
|
||||
const normalized = Array.isArray(muted) ? muted : [];
|
||||
return normalized;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveMutedList(ids: string[], nodeId?: string) {
|
||||
const storageKey = scopedDmStateKey(MUTED_KEY, nodeId);
|
||||
void (async () => {
|
||||
try {
|
||||
await persistIdentityBoundSensitiveValue(storageKey, MUTED_WRAP_INFO, ids, {
|
||||
legacyKey: MUTED_KEY,
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// ─── Sender seal decryption ─────────────────────────────────────────────────
|
||||
|
||||
export async function decryptSenderSeal(
|
||||
senderSeal: string,
|
||||
candidateDhPub: string,
|
||||
recipientId: string,
|
||||
expectedMsgId: string,
|
||||
): Promise<{ sender_id: string; seal_verified: boolean } | null> {
|
||||
const openLocal = async (): Promise<{ sender_id: string; seal_verified: boolean } | null> => {
|
||||
try {
|
||||
const sealEnvelope = unwrapSenderSealPayload(senderSeal);
|
||||
const sealText = await decryptSenderSealPayloadLocally(
|
||||
senderSeal,
|
||||
candidateDhPub,
|
||||
recipientId,
|
||||
expectedMsgId,
|
||||
);
|
||||
if (!sealText) {
|
||||
return null;
|
||||
}
|
||||
const seal = JSON.parse(sealText || '{}');
|
||||
const senderId = String(seal.sender_id || '');
|
||||
const publicKey = String(seal.public_key || '');
|
||||
const publicKeyAlgo = String(seal.public_key_algo || '');
|
||||
const sealMsgId = String(seal.msg_id || '');
|
||||
const sealTs = Number(seal.timestamp || 0);
|
||||
const signature = String(seal.signature || '');
|
||||
if (!senderId || !publicKey || !publicKeyAlgo || !sealMsgId || !signature) {
|
||||
return null;
|
||||
}
|
||||
if (sealMsgId !== expectedMsgId) {
|
||||
return null;
|
||||
}
|
||||
const isBound = await verifyNodeIdBindingFromPublicKey(publicKey, senderId);
|
||||
if (!isBound) {
|
||||
return { sender_id: senderId, seal_verified: false };
|
||||
}
|
||||
const sealMessage =
|
||||
sealEnvelope.version === 'v3'
|
||||
? `seal|v3|${sealMsgId}|${sealTs}|${recipientId}|${String(sealEnvelope.ephemeralPub || '')}`
|
||||
: `seal|${sealMsgId}|${sealTs}|${recipientId}`;
|
||||
const verified = await verifyRawSignature({
|
||||
message: sealMessage,
|
||||
signature,
|
||||
publicKey,
|
||||
publicKeyAlgo,
|
||||
});
|
||||
return { sender_id: senderId, seal_verified: verified };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const openHelper = async (): Promise<{ sender_id: string; seal_verified: boolean } | null> => {
|
||||
const opened = await openWormholeSenderSeal(
|
||||
senderSeal,
|
||||
candidateDhPub,
|
||||
recipientId,
|
||||
expectedMsgId,
|
||||
);
|
||||
return {
|
||||
sender_id: String(opened.sender_id || ''),
|
||||
seal_verified: Boolean(opened.seal_verified),
|
||||
};
|
||||
};
|
||||
|
||||
return recoverSenderSealWithFallback({
|
||||
wormholeReady: await isWormholeReady(),
|
||||
openLocal,
|
||||
openHelper,
|
||||
});
|
||||
}
|
||||
|
||||
export async function decryptSenderSealForContact(
|
||||
senderSeal: string,
|
||||
candidateDhPub: string,
|
||||
contact: Contact | undefined,
|
||||
ownNodeId: string,
|
||||
expectedMsgId: string,
|
||||
): Promise<{ sender_id: string; seal_verified: boolean } | null> {
|
||||
for (const recipientId of allDmPeerIds(ownNodeId, { sharedAlias: contact?.sharedAlias })) {
|
||||
const opened = await decryptSenderSeal(senderSeal, candidateDhPub, recipientId, expectedMsgId);
|
||||
if (opened) return opened;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface AliasDelta {
|
||||
updates: Partial<Contact>;
|
||||
promoted: Contact;
|
||||
}
|
||||
|
||||
export function promotePendingAlias(contactId: string, contact: Contact | undefined): { delta: AliasDelta; promoted: Contact } | null {
|
||||
if (!contact?.pendingSharedAlias) return null;
|
||||
const graceUntil = Number(contact.sharedAliasGraceUntil || 0);
|
||||
if (graceUntil > Date.now()) return null;
|
||||
const nextAlias = String(contact.pendingSharedAlias || '').trim();
|
||||
const currentAlias = String(contact.sharedAlias || '').trim();
|
||||
const updates: Partial<Contact> = {
|
||||
sharedAlias: nextAlias || currentAlias,
|
||||
pendingSharedAlias: undefined,
|
||||
sharedAliasGraceUntil: undefined,
|
||||
sharedAliasRotatedAt: Date.now(),
|
||||
previousSharedAliases: mergeAliasHistory([
|
||||
currentAlias,
|
||||
...(contact.previousSharedAliases || []),
|
||||
]),
|
||||
};
|
||||
const promoted: Contact = { ...contact, ...updates } as Contact;
|
||||
return { delta: { updates, promoted }, promoted };
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { WormholeGateKeyStatus, WormholeIdentity } from '@/mesh/wormholeIdentityClient';
|
||||
import type { Contact, NodeIdentity } from '@/mesh/meshIdentity';
|
||||
import type { SenderRecoveryState } from '@/mesh/requestSenderRecovery';
|
||||
|
||||
// ─── Domain types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Gate {
|
||||
gate_id: string;
|
||||
display_name: string;
|
||||
description?: string;
|
||||
welcome?: string;
|
||||
creator: string;
|
||||
rules: { min_overall_rep?: number };
|
||||
message_count: number;
|
||||
fixed?: boolean;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface InfoNetMessage {
|
||||
event_id: string;
|
||||
event_type?: string;
|
||||
node_id?: string;
|
||||
message?: string;
|
||||
reply_to?: string;
|
||||
ciphertext?: string;
|
||||
epoch?: number;
|
||||
nonce?: string;
|
||||
sender_ref?: string;
|
||||
format?: string;
|
||||
decrypted_message?: string;
|
||||
payload?: {
|
||||
gate?: string;
|
||||
ciphertext?: string;
|
||||
nonce?: string;
|
||||
sender_ref?: string;
|
||||
format?: string;
|
||||
envelope_hash?: string;
|
||||
reply_to?: string;
|
||||
};
|
||||
destination?: string;
|
||||
channel?: string;
|
||||
priority?: string;
|
||||
gate?: string;
|
||||
timestamp: number;
|
||||
sequence?: number;
|
||||
signature?: string;
|
||||
public_key?: string;
|
||||
public_key_algo?: string;
|
||||
protocol_version?: string;
|
||||
ephemeral?: boolean;
|
||||
system_seed?: boolean;
|
||||
fixed_gate?: boolean;
|
||||
gate_envelope?: string;
|
||||
envelope_hash?: string;
|
||||
}
|
||||
|
||||
export interface MeshtasticMessage {
|
||||
from: string;
|
||||
to?: string;
|
||||
text: string;
|
||||
region: string;
|
||||
root?: string;
|
||||
channel: string;
|
||||
timestamp: number | string;
|
||||
}
|
||||
|
||||
export interface DMMessage {
|
||||
sender_id: string;
|
||||
ciphertext: string;
|
||||
timestamp: number;
|
||||
msg_id: string;
|
||||
delivery_class?: 'request' | 'shared';
|
||||
transport?: 'reticulum' | 'relay';
|
||||
request_contract_version?: string;
|
||||
sender_recovery_required?: boolean;
|
||||
sender_recovery_state?: SenderRecoveryState;
|
||||
plaintext?: string;
|
||||
sender_seal?: string;
|
||||
seal_verified?: boolean;
|
||||
seal_resolution_failed?: boolean;
|
||||
}
|
||||
|
||||
export interface AccessRequest {
|
||||
sender_id: string;
|
||||
timestamp: number;
|
||||
dh_pub_key?: string;
|
||||
dh_algo?: string;
|
||||
geo_hint?: string;
|
||||
request_contract_version?: string;
|
||||
sender_recovery_required?: boolean;
|
||||
sender_recovery_state?: SenderRecoveryState;
|
||||
}
|
||||
|
||||
export interface SenderPopup {
|
||||
userId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
tab: Tab;
|
||||
publicKey?: string;
|
||||
publicKeyAlgo?: string;
|
||||
}
|
||||
|
||||
export interface GateReplyContext {
|
||||
eventId: string;
|
||||
gateId: string;
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
export type Tab = 'infonet' | 'meshtastic' | 'dms';
|
||||
export type DMView = 'contacts' | 'inbox' | 'chat' | 'muted';
|
||||
export type DmTransportMode = 'reticulum' | 'relay' | 'ready' | 'hidden' | 'degraded' | 'blocked';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_MESH_ROOTS = [
|
||||
'US',
|
||||
'EU_868',
|
||||
'EU_433',
|
||||
'CN',
|
||||
'JP',
|
||||
'KR',
|
||||
'TW',
|
||||
'RU',
|
||||
'IN',
|
||||
'ANZ',
|
||||
'ANZ_433',
|
||||
'NZ_865',
|
||||
'TH',
|
||||
'UA_868',
|
||||
'UA_433',
|
||||
'MY_433',
|
||||
'MY_919',
|
||||
'SG_923',
|
||||
'LORA_24',
|
||||
'EU',
|
||||
'AU',
|
||||
'UA',
|
||||
'BR',
|
||||
'AF',
|
||||
'ME',
|
||||
'SEA',
|
||||
'SA',
|
||||
'PL',
|
||||
] as const;
|
||||
|
||||
export const MSG_COLORS = ['text-cyan-300', 'text-[#ff69b4]', 'text-yellow-300', 'text-gray-200'];
|
||||
|
||||
export const DM_UNREAD_POLL_EXPANDED_MS = 15_000;
|
||||
export const DM_UNREAD_POLL_EXPANDED_JITTER_MS = 2_500;
|
||||
export const DM_UNREAD_POLL_COLLAPSED_MS = 60_000;
|
||||
export const DM_UNREAD_POLL_COLLAPSED_JITTER_MS = 10_000;
|
||||
export const GATE_MESSAGES_POLL_MS = 30_000;
|
||||
export const GATE_MESSAGES_POLL_JITTER_MS = 6_000;
|
||||
export const GATE_ACTIVITY_REFRESH_MS = 7_000;
|
||||
export const GATE_ACTIVITY_REFRESH_JITTER_MS = 2_500;
|
||||
export const DM_MESSAGES_POLL_MS = 10_000;
|
||||
export const DM_MESSAGES_POLL_JITTER_MS = 2_000;
|
||||
export const DM_DECOY_POLL_MS = 210_000;
|
||||
export const DM_DECOY_POLL_JITTER_MS = 90_000;
|
||||
export const ACCESS_REQUEST_BATCH_DELAY_MS = 1_400;
|
||||
export const ACCESS_REQUEST_BATCH_JITTER_MS = 900;
|
||||
export const SHARED_ALIAS_ROTATE_MS = 6 * 60 * 60 * 1000;
|
||||
export const SHARED_ALIAS_GRACE_MS = 45_000;
|
||||
|
||||
export const GATE_DECRYPT_CACHE_MAX = 256;
|
||||
export const INFO_VERIFICATION_CACHE_MAX = 512;
|
||||
|
||||
// ─── Props ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MeshChatProps {
|
||||
onFlyTo?: (lat: number, lng: number) => void;
|
||||
expanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
onSettingsClick?: () => void;
|
||||
onTerminalToggle?: () => void;
|
||||
launchRequest?: { tab: Tab; gate?: string; peerId?: string; showSas?: boolean; nonce: number } | null;
|
||||
}
|
||||
|
||||
// Re-export upstream types for convenience
|
||||
export type { Contact, NodeIdentity, WormholeGateKeyStatus, WormholeIdentity, SenderRecoveryState };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
import type { InfoNetMessage, DmTransportMode } from './types';
|
||||
export {
|
||||
buildGateAccessHeaders,
|
||||
gateAccessHeaderCache,
|
||||
invalidateGateAccessHeaders,
|
||||
pruneExpiredGateAccessHeaders,
|
||||
} from '@/mesh/gateAccessProof';
|
||||
|
||||
// ─── Pure helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
export function sortMeshRoots(
|
||||
roots: Iterable<string>,
|
||||
counts: Record<string, number> = {},
|
||||
currentRoot?: string,
|
||||
): string[] {
|
||||
const unique = Array.from(
|
||||
new Set(
|
||||
Array.from(roots)
|
||||
.map((root) => String(root || '').trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
return unique.sort((a, b) => {
|
||||
if (a === currentRoot) return -1;
|
||||
if (b === currentRoot) return 1;
|
||||
const countDelta = (counts[b] || 0) - (counts[a] || 0);
|
||||
if (countDelta !== 0) return countDelta;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeInfoNetMessage(message: InfoNetMessage): InfoNetMessage {
|
||||
const payload =
|
||||
message.payload && typeof message.payload === 'object'
|
||||
? message.payload
|
||||
: undefined;
|
||||
if (!payload) {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
gate: String(message.gate ?? payload.gate ?? ''),
|
||||
reply_to: String(message.reply_to ?? payload.reply_to ?? ''),
|
||||
ciphertext: String(message.ciphertext ?? payload.ciphertext ?? ''),
|
||||
nonce: String(message.nonce ?? payload.nonce ?? ''),
|
||||
sender_ref: String(message.sender_ref ?? payload.sender_ref ?? ''),
|
||||
format: String(message.format ?? payload.format ?? ''),
|
||||
envelope_hash: String(message.envelope_hash ?? payload.envelope_hash ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
export function gateDecryptCacheKey(message: InfoNetMessage): string {
|
||||
const eventId = String(message.event_id || '').trim();
|
||||
if (eventId) {
|
||||
return eventId;
|
||||
}
|
||||
return [
|
||||
String(message.gate || '').trim().toLowerCase(),
|
||||
String(message.ciphertext || '').trim(),
|
||||
String(message.sender_ref || '').trim(),
|
||||
String(message.nonce || '').trim(),
|
||||
].join('|');
|
||||
}
|
||||
|
||||
export function timeAgo(ts: number): string {
|
||||
const now = Date.now() / 1000;
|
||||
const diff = now - ts;
|
||||
if (diff < 60) return `${Math.floor(diff)}s`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||
return `${Math.floor(diff / 86400)}d`;
|
||||
}
|
||||
|
||||
export function dmTransportDisplay(mode: DmTransportMode): { label: string; className: string } {
|
||||
switch (mode) {
|
||||
case 'reticulum':
|
||||
return {
|
||||
label: 'DIRECT PRIVATE',
|
||||
className: 'border-green-500/30 text-green-400 bg-green-950/20',
|
||||
};
|
||||
case 'relay':
|
||||
return {
|
||||
label: 'RELAY FALLBACK',
|
||||
className: 'border-yellow-500/30 text-yellow-400 bg-yellow-950/20',
|
||||
};
|
||||
case 'ready':
|
||||
return {
|
||||
label: 'SECURE READY',
|
||||
className: 'border-cyan-500/30 text-cyan-400 bg-cyan-950/20',
|
||||
};
|
||||
case 'hidden':
|
||||
return {
|
||||
label: 'HIDDEN RELAY',
|
||||
className: 'border-cyan-500/30 text-cyan-300 bg-cyan-950/20',
|
||||
};
|
||||
case 'blocked':
|
||||
return {
|
||||
label: 'WORMHOLE BLOCKED',
|
||||
className: 'border-red-500/30 text-red-400 bg-red-950/20',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: 'PUBLIC / DEGRADED',
|
||||
className: 'border-orange-500/30 text-orange-400 bg-orange-950/20',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function randomHex(bytes: number = 16): string {
|
||||
const buf = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(buf);
|
||||
return Array.from(buf)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function jitterDelay(baseMs: number, spreadMs: number): number {
|
||||
const jitter = Math.floor((Math.random() * 2 - 1) * spreadMs);
|
||||
return Math.max(3000, baseMs + jitter);
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function randomBase64(bytes: number = 64): string {
|
||||
const buf = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(buf);
|
||||
return btoa(String.fromCharCode(...buf));
|
||||
}
|
||||
|
||||
// ─── Gate access header cache (module singleton) ─────────────────────────────
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Minimize2 } from 'lucide-react';
|
||||
import { useDataKey } from '@/hooks/useDataStore';
|
||||
import type { NewsArticle } from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* MiniMap — lightweight world-overview inset showing current viewport position
|
||||
* and high-severity news dots. Uses canvas for performance.
|
||||
*/
|
||||
|
||||
// Simple Plate Carrée projection
|
||||
const MAP_W = 200;
|
||||
const MAP_H = 100;
|
||||
|
||||
function latLngToXY(lat: number, lng: number): [number, number] {
|
||||
const x = ((lng + 180) / 360) * MAP_W;
|
||||
const y = ((90 - lat) / 180) * MAP_H;
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
// Simplified world coastline outline (major continental boundaries)
|
||||
// Approximate hull points for each continent
|
||||
const CONTINENTS: Array<[number, number][]> = [
|
||||
// North America
|
||||
[[72, -170], [72, -55], [48, -52], [25, -80], [15, -85], [15, -105], [30, -118], [48, -125], [60, -140], [72, -170]],
|
||||
// South America
|
||||
[[12, -70], [12, -35], [-5, -35], [-23, -42], [-55, -67], [-55, -75], [-15, -77], [0, -80], [12, -70]],
|
||||
// Europe
|
||||
[[72, -10], [72, 40], [55, 40], [47, 40], [38, 28], [36, -6], [43, -10], [48, -6], [55, -5], [72, -10]],
|
||||
// Africa
|
||||
[[37, -17], [37, 35], [30, 32], [12, 42], [-12, 44], [-34, 27], [-34, 18], [-5, 8], [5, -5], [15, -17], [37, -17]],
|
||||
// Asia
|
||||
[[72, 40], [72, 180], [55, 165], [30, 130], [22, 120], [8, 105], [1, 103], [22, 87], [25, 65], [30, 48], [42, 44], [47, 40], [55, 40], [72, 40]],
|
||||
// Australia
|
||||
[[-12, 130], [-12, 154], [-28, 154], [-38, 146], [-35, 117], [-20, 114], [-12, 130]],
|
||||
];
|
||||
|
||||
function getRiskColor(score: number): string {
|
||||
if (score >= 9) return '#ef4444';
|
||||
if (score >= 7) return '#f97316';
|
||||
if (score >= 4) return '#eab308';
|
||||
return '#22d3ee';
|
||||
}
|
||||
|
||||
export default function MiniMap() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const news = useDataKey('news') as NewsArticle[] | undefined;
|
||||
|
||||
const highSeverityDots = useMemo(() => {
|
||||
if (!news || !Array.isArray(news)) return [];
|
||||
return news
|
||||
.filter((n) => (n.risk_score || 0) >= 5 && (n.coords || (n.lat && n.lng)))
|
||||
.slice(0, 20)
|
||||
.map((n) => ({
|
||||
lat: n.coords?.[0] ?? n.lat,
|
||||
lng: n.coords?.[1] ?? n.lng,
|
||||
score: n.risk_score,
|
||||
}));
|
||||
}, [news]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = MAP_W * dpr;
|
||||
canvas.height = MAP_H * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// Clear
|
||||
ctx.clearRect(0, 0, MAP_W, MAP_H);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = 'rgba(5, 10, 20, 0.9)';
|
||||
ctx.fillRect(0, 0, MAP_W, MAP_H);
|
||||
|
||||
// Draw grid lines
|
||||
ctx.strokeStyle = 'rgba(6, 182, 212, 0.08)';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let lng = -180; lng <= 180; lng += 30) {
|
||||
const [x] = latLngToXY(0, lng);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, MAP_H);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let lat = -90; lat <= 90; lat += 30) {
|
||||
const [, y] = latLngToXY(lat, 0);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(MAP_W, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw continents
|
||||
ctx.strokeStyle = 'rgba(6, 182, 212, 0.25)';
|
||||
ctx.fillStyle = 'rgba(6, 182, 212, 0.04)';
|
||||
ctx.lineWidth = 0.8;
|
||||
for (const continent of CONTINENTS) {
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < continent.length; i++) {
|
||||
const [x, y] = latLngToXY(continent[i][0], continent[i][1]);
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw news threat dots
|
||||
for (const dot of highSeverityDots) {
|
||||
const [x, y] = latLngToXY(dot.lat, dot.lng);
|
||||
const color = getRiskColor(dot.score);
|
||||
|
||||
// Outer glow
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color + '40';
|
||||
ctx.fill();
|
||||
|
||||
// Inner dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 1.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = 'rgba(6, 182, 212, 0.2)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(0.5, 0.5, MAP_W - 1, MAP_H - 1);
|
||||
}, [highSeverityDots, collapsed]);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="absolute bottom-[6.5rem] right-[28rem] z-[200] pointer-events-auto px-2 py-1 bg-[var(--bg-panel)] border border-[var(--border-primary)] rounded-sm text-[9px] font-mono tracking-[0.15em] text-cyan-400 hover:border-cyan-600/40 transition-colors"
|
||||
>
|
||||
MAP
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-[6.5rem] right-[28rem] z-[200] pointer-events-auto"
|
||||
style={{
|
||||
width: MAP_W,
|
||||
height: MAP_H,
|
||||
boxShadow: '0 0 16px rgba(6, 182, 212, 0.08)',
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: MAP_W, height: MAP_H, borderRadius: '2px' }}
|
||||
/>
|
||||
|
||||
{/* Collapse button */}
|
||||
<button
|
||||
onClick={() => setCollapsed(true)}
|
||||
className="absolute top-1 right-1 p-0.5 text-[var(--text-muted)] hover:text-cyan-400 transition-colors"
|
||||
title="Collapse mini-map"
|
||||
>
|
||||
<Minimize2 size={10} />
|
||||
</button>
|
||||
|
||||
{/* Label */}
|
||||
<div className="absolute bottom-0.5 left-1 text-[10px] font-mono tracking-[0.2em] text-cyan-700/60 uppercase">
|
||||
OVERVIEW
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { AlertTriangle, Clock, ChevronDown, ChevronUp, ExternalLink } from 'lucide-react';
|
||||
import { AlertTriangle, Clock, Minus, Plus, ExternalLink, Brain, Loader2 } from 'lucide-react';
|
||||
import React, { useEffect, useRef, useCallback } from 'react';
|
||||
import WikiImage from '@/components/WikiImage';
|
||||
import type { SelectedEntity, RegionDossier, FimiData } from "@/types/dashboard";
|
||||
@@ -57,16 +57,43 @@ const AIRCRAFT_WIKI: Record<string, string> = {
|
||||
GLF6: 'Gulfstream G650', G280: 'Gulfstream G280', GA5C: 'Gulfstream G500/G600',
|
||||
GA6C: 'Gulfstream G500/G600', LJ35: 'Learjet 35', LJ45: 'Learjet 45', LJ60: 'Learjet 60',
|
||||
F900: 'Dassault Falcon 900', FA7X: 'Dassault Falcon 7X', FA8X: 'Dassault Falcon 8X',
|
||||
// Military common
|
||||
C130: 'Lockheed C-130 Hercules', C17: 'Boeing C-17 Globemaster III',
|
||||
// Military — US
|
||||
C130: 'Lockheed C-130 Hercules', C30J: 'Lockheed Martin C-130J Super Hercules',
|
||||
C17: 'Boeing C-17 Globemaster III',
|
||||
KC35: 'Boeing KC-135 Stratotanker', KC46: 'Boeing KC-46 Pegasus', K35R: 'Boeing KC-135 Stratotanker',
|
||||
E3CF: 'Boeing E-3 Sentry', E6B: 'Boeing E-6 Mercury', P8: 'Boeing P-8 Poseidon',
|
||||
B52H: 'Boeing B-52 Stratofortress', F16: 'General Dynamics F-16 Fighting Falcon',
|
||||
F15: 'McDonnell Douglas F-15 Eagle', F18H: 'Boeing F/A-18E/F Super Hornet',
|
||||
E3CF: 'Boeing E-3 Sentry', E3TF: 'Boeing E-3 Sentry', E6B: 'Boeing E-6 Mercury',
|
||||
P8: 'Boeing P-8 Poseidon', P8A: 'Boeing P-8 Poseidon',
|
||||
B52H: 'Boeing B-52 Stratofortress', B1: 'Rockwell B-1 Lancer', B1B: 'Rockwell B-1 Lancer',
|
||||
B2: 'Northrop Grumman B-2 Spirit', B21: 'Northrop Grumman B-21 Raider',
|
||||
F16: 'General Dynamics F-16 Fighting Falcon', F16C: 'General Dynamics F-16 Fighting Falcon',
|
||||
F15: 'McDonnell Douglas F-15 Eagle', F15E: 'McDonnell Douglas F-15E Strike Eagle',
|
||||
F18: 'Boeing F/A-18E/F Super Hornet', F18H: 'Boeing F/A-18E/F Super Hornet',
|
||||
FA18: 'Boeing F/A-18E/F Super Hornet',
|
||||
F35: 'Lockheed Martin F-35 Lightning II', F22: 'Lockheed Martin F-22 Raptor',
|
||||
A10: 'Fairchild Republic A-10 Thunderbolt II', V22: 'Bell Boeing V-22 Osprey',
|
||||
C5M: 'Lockheed C-5 Galaxy', C2: 'Grumman C-2 Greyhound',
|
||||
C5M: 'Lockheed C-5 Galaxy', C5: 'Lockheed C-5 Galaxy', C2: 'Grumman C-2 Greyhound',
|
||||
C40: 'Boeing C-40 Clipper', C37: 'Gulfstream V',
|
||||
E4B: 'Boeing E-4', E8: 'Northrop Grumman E-8 Joint STARS',
|
||||
RC135: 'Boeing RC-135', RC35: 'Boeing RC-135', R135: 'Boeing RC-135',
|
||||
U2: 'Lockheed U-2', U2S: 'Lockheed U-2',
|
||||
RQ4: 'Northrop Grumman RQ-4 Global Hawk', MQ9: 'General Atomics MQ-9 Reaper',
|
||||
MQ4C: 'Northrop Grumman MQ-4C Triton',
|
||||
H60: 'Sikorsky UH-60 Black Hawk', MH60: 'Sikorsky SH-60 Seahawk',
|
||||
CH47: 'Boeing CH-47 Chinook', H47: 'Boeing CH-47 Chinook',
|
||||
AH64: 'Boeing AH-64 Apache', H64: 'Boeing AH-64 Apache',
|
||||
EP3: 'Lockheed EP-3', P3: 'Lockheed P-3 Orion',
|
||||
T38: 'Northrop T-38 Talon', T6: 'Beechcraft T-6 Texan II',
|
||||
// Military — NATO / Allied
|
||||
EUFI: 'Eurofighter Typhoon', RFAL: 'Dassault Rafale', TORN: 'Panavia Tornado',
|
||||
GROB: 'Grob G 120TP', GRIS: 'Saab JAS 39 Gripen', J39: 'Saab JAS 39 Gripen',
|
||||
F4: 'McDonnell Douglas F-4 Phantom II', HAWK: 'BAE Systems Hawk',
|
||||
MRTT: 'Airbus A330 MRTT', A310M: 'Airbus A310 MRTT',
|
||||
P1: 'Kawasaki P-1', C1: 'Kawasaki C-1', C2JP: 'Kawasaki C-2 (aircraft)',
|
||||
// Military — Russian
|
||||
SU27: 'Sukhoi Su-27', SU30: 'Sukhoi Su-30', SU34: 'Sukhoi Su-34', SU35: 'Sukhoi Su-35',
|
||||
SU57: 'Sukhoi Su-57', MIG29: 'Mikoyan MiG-29', MIG31: 'Mikoyan MiG-31',
|
||||
TU95: 'Tupolev Tu-95', TU160: 'Tupolev Tu-160', TU22M: 'Tupolev Tu-22M',
|
||||
IL78: 'Ilyushin Il-78', A50: 'Beriev A-50',
|
||||
// GA
|
||||
C172: 'Cessna 172', C182: 'Cessna 182 Skylane', C206: 'Cessna 206', C208: 'Cessna 208 Caravan',
|
||||
C210: 'Cessna 210 Centurion', PA28: 'Piper PA-28 Cherokee', PA32: 'Piper PA-32',
|
||||
@@ -84,6 +111,91 @@ const AIRCRAFT_WIKI: Record<string, string> = {
|
||||
A400: 'Airbus A400M Atlas', C295: 'Airbus C-295',
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps plane_alert_db `ac_type` substrings → correct Wikipedia article titles.
|
||||
* The ac_type field often includes variant suffixes (e.g. "KC-135R", "F-16AM")
|
||||
* that don't correspond to Wikipedia article names. Checked in order — first match wins.
|
||||
*/
|
||||
const AC_TYPE_WIKI_OVERRIDES: [RegExp, string][] = [
|
||||
// US Fighters & Attack
|
||||
[/F-?22/i, 'Lockheed Martin F-22 Raptor'],
|
||||
[/F-?35/i, 'Lockheed Martin F-35 Lightning II'],
|
||||
[/F-?16/i, 'General Dynamics F-16 Fighting Falcon'],
|
||||
[/F-?15E/i, 'McDonnell Douglas F-15E Strike Eagle'],
|
||||
[/F-?15/i, 'McDonnell Douglas F-15 Eagle'],
|
||||
[/F.?\/A.?18/i, 'Boeing F/A-18E/F Super Hornet'],
|
||||
[/F-?18/i, 'Boeing F/A-18E/F Super Hornet'],
|
||||
[/A-?10/i, 'Fairchild Republic A-10 Thunderbolt II'],
|
||||
// US Bombers
|
||||
[/B-?52/i, 'Boeing B-52 Stratofortress'],
|
||||
[/B-?1B|B-?1\b/i, 'Rockwell B-1 Lancer'],
|
||||
[/B-?2\b/i, 'Northrop Grumman B-2 Spirit'],
|
||||
[/B-?21/i, 'Northrop Grumman B-21 Raider'],
|
||||
// US Tankers & Transport
|
||||
[/KC-?135/i, 'Boeing KC-135 Stratotanker'],
|
||||
[/KC-?46/i, 'Boeing KC-46 Pegasus'],
|
||||
[/KC-?10/i, 'McDonnell Douglas KC-10 Extender'],
|
||||
[/C-?17/i, 'Boeing C-17 Globemaster III'],
|
||||
[/C-?130J/i, 'Lockheed Martin C-130J Super Hercules'],
|
||||
[/C-?130/i, 'Lockheed C-130 Hercules'],
|
||||
[/C-?5/i, 'Lockheed C-5 Galaxy'],
|
||||
[/V-?22/i, 'Bell Boeing V-22 Osprey'],
|
||||
// US ISR & Special
|
||||
[/P-?8/i, 'Boeing P-8 Poseidon'],
|
||||
[/E-?3/i, 'Boeing E-3 Sentry'],
|
||||
[/E-?6/i, 'Boeing E-6 Mercury'],
|
||||
[/E-?4/i, 'Boeing E-4'],
|
||||
[/E-?8|Joint.?STARS/i, 'Northrop Grumman E-8 Joint STARS'],
|
||||
[/RC-?135/i, 'Boeing RC-135'],
|
||||
[/EP-?3/i, 'Lockheed EP-3'],
|
||||
[/P-?3/i, 'Lockheed P-3 Orion'],
|
||||
[/U-?2/i, 'Lockheed U-2'],
|
||||
[/RQ-?4|Global.?Hawk/i, 'Northrop Grumman RQ-4 Global Hawk'],
|
||||
[/MQ-?9|Reaper/i, 'General Atomics MQ-9 Reaper'],
|
||||
[/MQ-?4C|Triton/i, 'Northrop Grumman MQ-4C Triton'],
|
||||
// US Helicopters
|
||||
[/AH-?64|Apache/i, 'Boeing AH-64 Apache'],
|
||||
[/CH-?47|Chinook/i, 'Boeing CH-47 Chinook'],
|
||||
[/UH-?60|Black.?Hawk/i, 'Sikorsky UH-60 Black Hawk'],
|
||||
[/SH-?60|MH-?60|Seahawk/i, 'Sikorsky SH-60 Seahawk'],
|
||||
// NATO / Allied
|
||||
[/Eurofighter|Typhoon/i, 'Eurofighter Typhoon'],
|
||||
[/Rafale/i, 'Dassault Rafale'],
|
||||
[/Tornado/i, 'Panavia Tornado'],
|
||||
[/Gripen/i, 'Saab JAS 39 Gripen'],
|
||||
[/A400M/i, 'Airbus A400M Atlas'],
|
||||
[/A330\s*MRTT|Voyager/i, 'Airbus A330 MRTT'],
|
||||
[/Hawk\s*T/i, 'BAE Systems Hawk'],
|
||||
// Russian
|
||||
[/Su-?57/i, 'Sukhoi Su-57'],
|
||||
[/Su-?35/i, 'Sukhoi Su-35'],
|
||||
[/Su-?34/i, 'Sukhoi Su-34'],
|
||||
[/Su-?30/i, 'Sukhoi Su-30'],
|
||||
[/Su-?27/i, 'Sukhoi Su-27'],
|
||||
[/Su-?25/i, 'Sukhoi Su-25'],
|
||||
[/MiG-?31/i, 'Mikoyan MiG-31'],
|
||||
[/MiG-?29/i, 'Mikoyan MiG-29'],
|
||||
[/Tu-?160/i, 'Tupolev Tu-160'],
|
||||
[/Tu-?95/i, 'Tupolev Tu-95'],
|
||||
[/Tu-?22M/i, 'Tupolev Tu-22M'],
|
||||
[/Il-?76/i, 'Ilyushin Il-76'],
|
||||
[/Il-?78/i, 'Ilyushin Il-78'],
|
||||
[/A-?50\b/i, 'Beriev A-50'],
|
||||
// Chinese
|
||||
[/J-?20/i, 'Chengdu J-20'],
|
||||
[/J-?16/i, 'Shenyang J-16'],
|
||||
[/J-?10/i, 'Chengdu J-10'],
|
||||
[/Y-?20/i, 'Xi\'an Y-20'],
|
||||
];
|
||||
|
||||
/** Resolve a plane_alert_db ac_type string to a Wikipedia article title. */
|
||||
function resolveAcTypeWiki(acType: string): string | null {
|
||||
for (const [pattern, wikiTitle] of AC_TYPE_WIKI_OVERRIDES) {
|
||||
if (pattern.test(acType)) return wikiTitle;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Module-level cache for Wikipedia thumbnails (persists across re-renders)
|
||||
const _wikiThumbCache: Record<string, { url: string | null; loading: boolean }> = {};
|
||||
|
||||
@@ -124,7 +236,7 @@ const VESSEL_TYPE_WIKI: Record<string, string> = {
|
||||
'military_vessel': 'https://en.wikipedia.org/wiki/Warship',
|
||||
};
|
||||
|
||||
function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, onArticleClick }: { selectedEntity?: SelectedEntity | null, regionDossier?: RegionDossier | null, regionDossierLoading?: boolean, onArticleClick?: (idx: number, lat?: number, lng?: number) => void }) {
|
||||
function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, onArticleClick }: { selectedEntity?: SelectedEntity | null, regionDossier?: RegionDossier | null, regionDossierLoading?: boolean, onArticleClick?: (idx: number, lat?: number, lng?: number, title?: string) => void }) {
|
||||
const data = useDataKeys([
|
||||
'news', 'fimi', 'commercial_flights', 'private_flights', 'private_jets',
|
||||
'military_flights', 'tracked_flights', 'ships', 'gdelt', 'liveuamap',
|
||||
@@ -133,6 +245,9 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [expandedIndexes, setExpandedIndexes] = useState<number[]>([]);
|
||||
const [fimiExpanded, setFimiExpanded] = useState(false);
|
||||
const [aiSummaryOpen, setAiSummaryOpen] = useState(false);
|
||||
const [aiSummary, setAiSummary] = useState<any>(null);
|
||||
const [aiSummaryLoading, setAiSummaryLoading] = useState(false);
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
// Intentionally omitting map click triggers for expanding
|
||||
@@ -222,24 +337,24 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
className="w-full bg-black/60 backdrop-blur-sm border border-emerald-800 flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(0,255,128,0.2)] pointer-events-auto overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<div className="p-3 border-b border-emerald-500/30 bg-emerald-950/40 flex justify-between items-center">
|
||||
<h2 className="text-xs tracking-widest font-bold text-emerald-400">REGION DOSSIER</h2>
|
||||
<span className="text-[8px] text-[var(--text-muted)]">
|
||||
<h2 className="text-sm tracking-widest font-bold text-emerald-400">REGION DOSSIER</h2>
|
||||
<span className="text-[10px] text-[var(--text-muted)]">
|
||||
{selectedEntity.extra ? `${selectedEntity.extra.lat.toFixed(3)}, ${selectedEntity.extra.lng.toFixed(3)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{regionDossierLoading ? (
|
||||
<div className="p-6 flex items-center justify-center">
|
||||
<span className="text-emerald-400 text-[10px] font-mono tracking-widest">COMPILING INTELLIGENCE...</span>
|
||||
<span className="text-emerald-400 text-[12px] font-mono tracking-widest">COMPILING INTELLIGENCE...</span>
|
||||
</div>
|
||||
) : d && !d.error ? (
|
||||
<div className="p-3 flex flex-col gap-1.5 max-h-[500px] overflow-y-auto styled-scrollbar text-[10px]">
|
||||
<div className="p-3 flex flex-col gap-2 max-h-[500px] overflow-y-auto styled-scrollbar text-[12px]">
|
||||
{d.warning && (
|
||||
<div className="mb-2 p-2 bg-amber-950/40 border border-amber-800/50 text-[9px] text-amber-300 leading-relaxed">
|
||||
<div className="mb-2 p-2 bg-amber-950/40 border border-amber-800/50 text-[11px] text-amber-300 leading-relaxed">
|
||||
{d.warning}
|
||||
</div>
|
||||
)}
|
||||
{/* COUNTRY */}
|
||||
<div className="text-[9px] text-emerald-500 tracking-widest font-bold border-b border-emerald-900/50 pb-1">COUNTRY LEVEL {d.country?.flag_emoji || ''}</div>
|
||||
<div className="text-[11px] text-emerald-500 tracking-widest font-bold border-b border-emerald-900/50 pb-1">COUNTRY LEVEL {d.country?.flag_emoji || ''}</div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">COUNTRY</span><span className="text-[var(--text-primary)] font-bold">{d.country?.name}</span></div>
|
||||
{d.country?.official_name && d.country.official_name !== d.country.name && (
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">OFFICIAL</span><span className="text-[var(--text-secondary)] text-right max-w-[180px]">{d.country.official_name}</span></div>
|
||||
@@ -260,12 +375,12 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
{/* LOCAL */}
|
||||
{(d.local?.name || d.local?.state) && (
|
||||
<>
|
||||
<div className="text-[9px] text-emerald-500 tracking-widest font-bold border-b border-emerald-900/50 pb-1 mt-2">LOCAL LEVEL</div>
|
||||
<div className="text-[11px] text-emerald-500 tracking-widest font-bold border-b border-emerald-900/50 pb-1 mt-2">LOCAL LEVEL</div>
|
||||
{d.local.name && <div className="flex justify-between"><span className="text-[var(--text-muted)]">LOCALITY</span><span className="text-[var(--text-primary)] font-bold">{d.local.name}</span></div>}
|
||||
{d.local.state && <div className="flex justify-between"><span className="text-[var(--text-muted)]">STATE/PROVINCE</span><span className="text-[var(--text-primary)] font-bold">{d.local.state}</span></div>}
|
||||
{d.local.description && <div className="flex justify-between"><span className="text-[var(--text-muted)]">TYPE</span><span className="text-[var(--text-secondary)]">{d.local.description}</span></div>}
|
||||
{d.local.summary && (
|
||||
<div className="mt-1 p-2 bg-black/60 border border-emerald-800/50 text-[9px] text-[var(--text-secondary)] leading-relaxed">
|
||||
<div className="mt-1 p-2 bg-black/60 border border-emerald-800/50 text-[11px] text-[var(--text-secondary)] leading-relaxed">
|
||||
<span className="text-emerald-400 font-bold">>_ INTEL: </span>
|
||||
{d.local.summary.length > 500 ? d.local.summary.substring(0, 500) + '...' : d.local.summary}
|
||||
</div>
|
||||
@@ -276,9 +391,9 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
{/* Sentinel-2 imagery now shown as map popup — see MaplibreViewer */}
|
||||
</div>
|
||||
) : d?.error ? (
|
||||
<div className="p-4 text-[var(--text-secondary)] text-[10px]">{d.error}</div>
|
||||
<div className="p-4 text-[var(--text-secondary)] text-[12px]">{d.error}</div>
|
||||
) : (
|
||||
<div className="p-4 text-red-400 text-[10px]">INTEL UNAVAILABLE</div>
|
||||
<div className="p-4 text-red-400 text-[12px]">INTEL UNAVAILABLE</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
@@ -303,7 +418,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col gap-2 text-[10px]">
|
||||
<div className="text-[9px] text-green-500 tracking-widest font-bold border-b border-green-900/50 pb-1">
|
||||
<div className="text-[11px] text-green-500 tracking-widest font-bold border-b border-green-900/50 pb-1">
|
||||
ATTRIBUTION
|
||||
</div>
|
||||
<div className="text-green-300/90">
|
||||
@@ -318,7 +433,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-[9px] text-green-500 tracking-widest font-bold border-b border-green-900/50 pb-1 mt-2">
|
||||
<div className="text-[11px] text-green-500 tracking-widest font-bold border-b border-green-900/50 pb-1 mt-2">
|
||||
HOST
|
||||
</div>
|
||||
<div className="flex justify-between"><span className="text-[var(--text-muted)]">IP</span><span className="text-green-300 font-bold">{host.ip || 'UNKNOWN'}</span></div>
|
||||
@@ -340,7 +455,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
)}
|
||||
{Array.isArray(host.services) && host.services.length > 0 && (
|
||||
<>
|
||||
<div className="text-[9px] text-green-500 tracking-widest font-bold border-b border-green-900/50 pb-1 mt-2">
|
||||
<div className="text-[11px] text-green-500 tracking-widest font-bold border-b border-green-900/50 pb-1 mt-2">
|
||||
SERVICES
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -356,12 +471,12 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
{service.product || 'Unknown service'}
|
||||
</div>
|
||||
{service.tags?.length > 0 && (
|
||||
<div className="mt-1 text-[9px] text-green-500/80">
|
||||
<div className="mt-1 text-[11px] text-green-500/80">
|
||||
TAGS: {service.tags.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{service.banner_excerpt && (
|
||||
<div className="mt-1 text-[9px] text-green-300/90 leading-relaxed">
|
||||
<div className="mt-1 text-[11px] text-green-300/90 leading-relaxed">
|
||||
{service.banner_excerpt}
|
||||
</div>
|
||||
)}
|
||||
@@ -371,7 +486,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
</>
|
||||
)}
|
||||
{host.data_snippet && (
|
||||
<div className="mt-2 border border-green-900/50 bg-black/50 p-2 text-[9px] text-green-300/90 leading-relaxed">
|
||||
<div className="mt-2 border border-green-900/50 bg-black/50 p-2 text-[11px] text-green-300/90 leading-relaxed">
|
||||
<span className="text-green-400 font-bold">>_ BANNER: </span>
|
||||
{host.data_snippet}
|
||||
</div>
|
||||
@@ -450,46 +565,96 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
<span className={`text-xs font-bold ${headerColor}`}>UNKNOWN</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Owner/Operator Wikipedia photo */}
|
||||
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" && (() => {
|
||||
const wikiSlug = flight.alert_wiki || flight.alert_operator.replace(/\s*\(.*?\)\s*/g, '').trim().replace(/ /g, '_');
|
||||
const wikiHref = `https://en.wikipedia.org/wiki/${encodeURIComponent(wikiSlug)}`;
|
||||
{/* Primary image: military → aircraft model photo; everything else → operator/company photo */}
|
||||
{(() => {
|
||||
// Categories where the aircraft model should be the primary image
|
||||
const MILITARY_CATEGORIES = new Set([
|
||||
'USAF', 'RAF', 'GAF', 'Royal Navy Fleet Air Arm', 'Army Air Corps',
|
||||
'Other Air Forces', 'Other Navies', 'United States Navy',
|
||||
'United States Marine Corps', 'Special Forces', 'Gunship', 'Nuclear',
|
||||
'UAV', 'Coastguard', 'Da Comrade', 'Hired Gun', 'Oxcart', 'Zoomies',
|
||||
'Toy Soldiers', 'Police Forces', 'Flying Doctors', 'Aerial Firefighter',
|
||||
]);
|
||||
const cat = flight.alert_category || '';
|
||||
const isMilitary = MILITARY_CATEGORIES.has(cat);
|
||||
|
||||
// Resolve aircraft model wiki info (for link or image depending on context)
|
||||
let acWikiTitle = flight.model ? AIRCRAFT_WIKI[flight.model] : undefined;
|
||||
if (!acWikiTitle && flight.alert_type && flight.alert_type !== "UNKNOWN") {
|
||||
acWikiTitle = resolveAcTypeWiki(flight.alert_type) || flight.alert_type;
|
||||
}
|
||||
const acModelWikiUrl = acWikiTitle ? `https://en.wikipedia.org/wiki/${acWikiTitle.replace(/ /g, '_')}` : null;
|
||||
|
||||
// Resolve operator wiki info
|
||||
const operatorSlug = flight.alert_wiki || (flight.alert_operator && flight.alert_operator !== "UNKNOWN"
|
||||
? flight.alert_operator.replace(/\s*\(.*?\)\s*/g, '').trim().replace(/ /g, '_')
|
||||
: null);
|
||||
const operatorWikiUrl = operatorSlug ? `https://en.wikipedia.org/wiki/${encodeURIComponent(operatorSlug)}` : null;
|
||||
|
||||
const accentClass = ac === 'pink' ? 'hover:border-pink-500/50' : ac === 'red' ? 'hover:border-red-500/50' : 'hover:border-cyan-500/50';
|
||||
|
||||
if (isMilitary) {
|
||||
// MILITARY: aircraft model photo as primary image, operator as text link above
|
||||
return acModelWikiUrl ? (
|
||||
<div className="border-b border-[var(--border-primary)] pb-2">
|
||||
<WikiImage
|
||||
wikiUrl={acModelWikiUrl}
|
||||
label={acWikiTitle || flight.model}
|
||||
maxH="max-h-36"
|
||||
accent={accentClass}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
// NON-MILITARY (tracked jets, celebs, companies, airlines):
|
||||
// Operator/company photo as primary image
|
||||
// Aircraft model as a text link below
|
||||
return (
|
||||
<div className="border-b border-[var(--border-primary)] pb-2">
|
||||
<WikiImage
|
||||
wikiUrl={wikiHref}
|
||||
label={flight.alert_operator}
|
||||
maxH="max-h-36"
|
||||
accent={ac === 'pink' ? 'hover:border-pink-500/50' : ac === 'red' ? 'hover:border-red-500/50' : 'hover:border-cyan-500/50'}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{operatorWikiUrl && (
|
||||
<div className="border-b border-[var(--border-primary)] pb-2">
|
||||
<WikiImage
|
||||
wikiUrl={operatorWikiUrl}
|
||||
label={flight.alert_operator || 'Operator'}
|
||||
maxH="max-h-36"
|
||||
accent={accentClass}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{acModelWikiUrl && (
|
||||
<div className="border-b border-[var(--border-primary)] pb-1">
|
||||
<a href={acModelWikiUrl} target="_blank" rel="noopener noreferrer"
|
||||
className="text-[10px] text-cyan-400 hover:text-cyan-300 underline inline-block">
|
||||
📖 {acWikiTitle || flight.alert_type || flight.model} — Wikipedia →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{/* Aircraft model Wikipedia photo */}
|
||||
{aircraftImgUrl && (
|
||||
<div className="border-b border-[var(--border-primary)] pb-2">
|
||||
<a href={aircraftWikiUrl || '#'} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<img
|
||||
src={aircraftImgUrl}
|
||||
alt={AIRCRAFT_WIKI[flight.model] || flight.model}
|
||||
className={`w-full h-auto max-h-28 object-cover border border-[var(--border-primary)]/50 ${ac === 'pink' ? 'hover:border-pink-500/50' : 'hover:border-cyan-500/50'} transition-colors`}
|
||||
/>
|
||||
</a>
|
||||
{aircraftWikiUrl && (
|
||||
<a href={aircraftWikiUrl} target="_blank" rel="noopener noreferrer"
|
||||
className="text-[10px] text-cyan-400 hover:text-cyan-300 underline mt-1 inline-block">
|
||||
📖 {AIRCRAFT_WIKI[flight.model] || flight.model} — Wikipedia →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">CATEGORY</span>
|
||||
<span className={`text-xs font-bold ${headerColor}`}>{flight.alert_category || "N/A"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">AIRCRAFT</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.alert_type || flight.model || "UNKNOWN"}</span>
|
||||
{(() => {
|
||||
const acLabel = flight.alert_type || flight.model || "UNKNOWN";
|
||||
let acLink = flight.model ? AIRCRAFT_WIKI[flight.model] : undefined;
|
||||
if (!acLink && flight.alert_type && flight.alert_type !== "UNKNOWN") {
|
||||
acLink = resolveAcTypeWiki(flight.alert_type) || undefined;
|
||||
}
|
||||
const acHref = acLink ? `https://en.wikipedia.org/wiki/${acLink.replace(/ /g, '_')}` : null;
|
||||
return acHref ? (
|
||||
<a href={acHref} target="_blank" rel="noreferrer"
|
||||
className="text-xs font-bold text-cyan-400 hover:text-cyan-300 underline transition-opacity">
|
||||
{acLabel}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{acLabel}</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">REGISTRATION</span>
|
||||
@@ -523,12 +688,12 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">EMISSIONS ESTIMATE</span>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
|
||||
<div className="text-[8px] text-[var(--text-muted)] tracking-widest">FUEL BURN</div>
|
||||
<div className="text-xs font-bold text-orange-400">{flight.emissions ? <>{flight.emissions.fuel_gph} <span className="text-[8px] text-[var(--text-muted)] font-normal">GPH</span></> : 'UNKNOWN'}</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">FUEL BURN</div>
|
||||
<div className="text-xs font-bold text-orange-400">{flight.emissions ? <>{flight.emissions.fuel_gph} <span className="text-[11px] text-[var(--text-muted)] font-normal">GPH</span></> : 'UNKNOWN'}</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
|
||||
<div className="text-[8px] text-[var(--text-muted)] tracking-widest">CO2 OUTPUT</div>
|
||||
<div className="text-xs font-bold text-red-400">{flight.emissions ? <>{flight.emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[8px] text-[var(--text-muted)] font-normal">KG/HR</span></> : 'UNKNOWN'}</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">CO2 OUTPUT</div>
|
||||
<div className="text-xs font-bold text-red-400">{flight.emissions ? <>{flight.emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">KG/HR</span></> : 'UNKNOWN'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -600,16 +765,16 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
} else if ('airline_code' in flight && flight.airline_code) {
|
||||
// Use the airline code resolved from adsb.lol routeset API
|
||||
const codeMap: Record<string, string> = {
|
||||
"UAL": "UNITED AIRLINES", "DAL": "DELTA AIR LINES", "SWA": "SOUTHWEST AIRLINES",
|
||||
"AAL": "AMERICAN AIRLINES", "BAW": "BRITISH AIRWAYS", "AFR": "AIR FRANCE",
|
||||
"JBU": "JETBLUE AIRWAYS", "NKS": "SPIRIT AIRLINES", "THY": "TURKISH AIRLINES",
|
||||
"UAE": "EMIRATES", "QFA": "QANTAS", "ACA": "AIR CANADA",
|
||||
"FFT": "FRONTIER AIRLINES", "WJA": "WESTJET", "RPA": "REPUBLIC AIRWAYS",
|
||||
"SKW": "SKYWEST AIRLINES", "ENY": "ENVOY AIR", "ASA": "ALASKA AIRLINES",
|
||||
"HAL": "HAWAIIAN AIRLINES", "DLH": "LUFTHANSA", "KLM": "KLM",
|
||||
"EZY": "EASYJET", "RYR": "RYANAIR", "SIA": "SINGAPORE AIRLINES",
|
||||
"CPA": "CATHAY PACIFIC", "ANA": "ALL NIPPON AIRWAYS", "JAL": "JAPAN AIRLINES",
|
||||
"QTR": "QATAR AIRWAYS", "ETD": "ETIHAD AIRWAYS", "SAS": "SAS SCANDINAVIAN"
|
||||
"UAL": "United Airlines", "DAL": "Delta Air Lines", "SWA": "Southwest Airlines",
|
||||
"AAL": "American Airlines", "BAW": "British Airways", "AFR": "Air France",
|
||||
"JBU": "JetBlue Airways", "NKS": "Spirit Airlines", "THY": "Turkish Airlines",
|
||||
"UAE": "Emirates", "QFA": "Qantas", "ACA": "Air Canada",
|
||||
"FFT": "Frontier Airlines", "WJA": "WestJet", "RPA": "Republic Airways",
|
||||
"SKW": "SkyWest Airlines", "ENY": "Envoy Air", "ASA": "Alaska Airlines",
|
||||
"HAL": "Hawaiian Airlines", "DLH": "Lufthansa", "KLM": "KLM",
|
||||
"EZY": "EasyJet", "RYR": "Ryanair", "SIA": "Singapore Airlines",
|
||||
"CPA": "Cathay Pacific", "ANA": "All Nippon Airways", "JAL": "Japan Airlines",
|
||||
"QTR": "Qatar Airways", "ETD": "Etihad Airways", "SAS": "SAS Scandinavian"
|
||||
};
|
||||
airline = codeMap[flight.airline_code] || flight.airline_code;
|
||||
} else if (callsign !== "UNKNOWN") {
|
||||
@@ -633,8 +798,30 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
<div className="p-4 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">OPERATOR</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{airline}</span>
|
||||
{selectedEntity.type !== 'military_flight' && airline && airline !== 'COMMERCIAL FLIGHT' && airline !== 'UNKNOWN' ? (
|
||||
<a
|
||||
href={`https://en.wikipedia.org/wiki/${encodeURIComponent(airline.replace(/ /g, '_'))}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs font-bold text-cyan-400 hover:text-cyan-300 underline"
|
||||
>
|
||||
{airline}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{airline}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Commercial: Airline company Wikipedia image */}
|
||||
{selectedEntity.type !== 'military_flight' && airline && airline !== 'COMMERCIAL FLIGHT' && airline !== 'UNKNOWN' && (
|
||||
<div className="border-b border-[var(--border-primary)] pb-2">
|
||||
<WikiImage
|
||||
wikiUrl={`https://en.wikipedia.org/wiki/${encodeURIComponent(airline.replace(/ /g, '_'))}`}
|
||||
label={airline}
|
||||
maxH="max-h-32"
|
||||
accent="hover:border-cyan-500/50"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">REGISTRATION</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.registration || "N/A"}</span>
|
||||
@@ -643,8 +830,56 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
<span className="text-[var(--text-muted)] text-[10px]">AIRCRAFT MODEL</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.model || "UNKNOWN"}</span>
|
||||
</div>
|
||||
{/* Aircraft photo + Wikipedia link */}
|
||||
{(aircraftImgUrl || aircraftImgLoading || aircraftWikiUrl) && (
|
||||
{/* Military: Aircraft model Wikipedia image (gold accent) */}
|
||||
{selectedEntity.type === 'military_flight' && (() => {
|
||||
// Resolve model to Wikipedia article — ICAO code first, then ac_type regex
|
||||
const milAcType = (flight as Record<string, any>).alert_type as string | undefined;
|
||||
const milWikiTitle = (flight.model ? AIRCRAFT_WIKI[flight.model] : undefined)
|
||||
|| (milAcType ? resolveAcTypeWiki(milAcType) : null)
|
||||
|| (flight.model ? resolveAcTypeWiki(flight.model) : null);
|
||||
const milModelUrl = milWikiTitle ? `https://en.wikipedia.org/wiki/${milWikiTitle.replace(/ /g, '_')}` : null;
|
||||
if (milModelUrl) {
|
||||
return (
|
||||
<div className="border-b border-[var(--border-primary)] pb-3">
|
||||
<WikiImage
|
||||
wikiUrl={milModelUrl}
|
||||
label={milWikiTitle || flight.model}
|
||||
maxH="max-h-36"
|
||||
accent="hover:border-amber-400/60"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Fall back to cached thumbnail if available
|
||||
if (aircraftImgUrl || aircraftImgLoading) {
|
||||
return (
|
||||
<div className="border-b border-[var(--border-primary)] pb-3">
|
||||
{aircraftImgLoading && (
|
||||
<div className="w-full h-24 bg-[var(--bg-tertiary)]/60" />
|
||||
)}
|
||||
{aircraftImgUrl && (
|
||||
<a href={aircraftWikiUrl || '#'} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<img
|
||||
src={aircraftImgUrl}
|
||||
alt={AIRCRAFT_WIKI[flight.model] || flight.model}
|
||||
className="w-full h-auto max-h-32 object-cover border border-amber-500/30 hover:border-amber-400/60 transition-colors"
|
||||
style={{ imageRendering: 'auto' }}
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
{aircraftWikiUrl && (
|
||||
<a href={aircraftWikiUrl} target="_blank" rel="noopener noreferrer"
|
||||
className="text-[10px] text-amber-400 hover:text-amber-300 underline mt-1 inline-block">
|
||||
📖 {AIRCRAFT_WIKI[flight.model] || flight.model} — Wikipedia →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
{/* Non-military: Aircraft model photo (secondary, below airline image) */}
|
||||
{selectedEntity.type !== 'military_flight' && (aircraftImgUrl || aircraftImgLoading || aircraftWikiUrl) && (
|
||||
<div className="border-b border-[var(--border-primary)] pb-3">
|
||||
{aircraftImgLoading && (
|
||||
<div className="w-full h-24 bg-[var(--bg-tertiary)]/60" />
|
||||
@@ -693,12 +928,12 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">EMISSIONS ESTIMATE</span>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
|
||||
<div className="text-[8px] text-[var(--text-muted)] tracking-widest">FUEL BURN</div>
|
||||
<div className="text-xs font-bold text-orange-400">{flight.emissions ? <>{flight.emissions.fuel_gph} <span className="text-[8px] text-[var(--text-muted)] font-normal">GPH</span></> : 'UNKNOWN'}</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">FUEL BURN</div>
|
||||
<div className="text-xs font-bold text-orange-400">{flight.emissions ? <>{flight.emissions.fuel_gph} <span className="text-[11px] text-[var(--text-muted)] font-normal">GPH</span></> : 'UNKNOWN'}</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
|
||||
<div className="text-[8px] text-[var(--text-muted)] tracking-widest">CO2 OUTPUT</div>
|
||||
<div className="text-xs font-bold text-red-400">{flight.emissions ? <>{flight.emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[8px] text-[var(--text-muted)] font-normal">KG/HR</span></> : 'UNKNOWN'}</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">CO2 OUTPUT</div>
|
||||
<div className="text-xs font-bold text-red-400">{flight.emissions ? <>{flight.emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">KG/HR</span></> : 'UNKNOWN'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -875,7 +1110,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
{headline || domain || 'View Article'}
|
||||
</span>
|
||||
{headline && domain && (
|
||||
<span className="text-[var(--text-muted)] text-[9px] block mt-0.5">{domain}</span>
|
||||
<span className="text-[var(--text-muted)] text-[11px] block mt-0.5">{domain}</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
@@ -980,7 +1215,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">MARKET CORRELATION</span>
|
||||
<div className="p-2 bg-purple-950/30 border border-purple-500/30 rounded-sm">
|
||||
<div className="text-[10px] text-purple-300 font-bold leading-tight mb-1">{item.prediction_odds.title}</div>
|
||||
<div className="flex items-center gap-3 text-[9px] font-mono">
|
||||
<div className="flex items-center gap-3 text-[11px] font-mono">
|
||||
<span className="text-white font-bold">CONSENSUS: {item.prediction_odds.consensus_pct}%</span>
|
||||
{item.prediction_odds.polymarket_pct != null && <span className="text-cyan-400">Polymarket {item.prediction_odds.polymarket_pct}%</span>}
|
||||
{item.prediction_odds.kalshi_pct != null && <span className="text-orange-400">Kalshi {item.prediction_odds.kalshi_pct}%</span>}
|
||||
@@ -989,7 +1224,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
</div>
|
||||
)}
|
||||
{item.machine_assessment && (
|
||||
<div className="mt-2 p-2 bg-black/60 border border-cyan-800/50 rounded-sm text-[9px] text-cyan-400 font-mono leading-tight relative overflow-hidden shadow-[inset_0_0_10px_rgba(0,255,255,0.05)]">
|
||||
<div className="mt-2 p-2 bg-black/60 border border-cyan-800/50 rounded-sm text-[11px] text-cyan-400 font-mono leading-tight relative overflow-hidden shadow-[inset_0_0_10px_rgba(0,255,255,0.05)]">
|
||||
<div className="absolute top-0 left-0 w-[2px] h-full bg-cyan-500 animate-pulse"></div>
|
||||
<span className="font-bold text-white">>_ SYS.ANALYSIS: </span>
|
||||
<span className="text-cyan-300 opacity-90">{item.machine_assessment}</span>
|
||||
@@ -1053,19 +1288,48 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className={`w-full bg-[#0a0a0a]/90 backdrop-blur-sm border border-cyan-900/40 flex flex-col z-10 font-mono pointer-events-auto overflow-hidden transition-all duration-300 ${isMinimized ? 'h-[50px] flex-shrink-0' : 'flex-1 min-h-0'}`}
|
||||
className={`w-full bg-[#0a0a0a]/90 backdrop-blur-sm border border-cyan-900/40 flex flex-col z-10 font-mono pointer-events-auto overflow-hidden transition-all duration-300 ${isMinimized ? 'flex-shrink-0' : 'flex-1 min-h-0'}`}
|
||||
>
|
||||
<div
|
||||
className="p-3 border-b border-[var(--border-primary)]/50 relative overflow-hidden cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors"
|
||||
className="px-3 py-2.5 border-b border-cyan-900/40 relative overflow-hidden cursor-pointer hover:bg-cyan-950/30 transition-colors"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
<div className="flex justify-between items-center relative z-10">
|
||||
<h2 className="text-xs tracking-widest font-bold text-cyan-400 flex items-center gap-2">
|
||||
<AlertTriangle size={14} /> GLOBAL THREAT INTERCEPT
|
||||
</h2>
|
||||
<button className="text-cyan-500 hover:text-[var(--text-primary)] transition-colors">
|
||||
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</button>
|
||||
<div className="flex items-center justify-between relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle size={16} className="text-cyan-400" />
|
||||
<span className="text-[12px] text-cyan-400 font-mono tracking-widest font-bold">
|
||||
GLOBAL THREAT INTERCEPT
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const next = !aiSummaryOpen;
|
||||
setAiSummaryOpen(next);
|
||||
if (next && !aiSummary) {
|
||||
setAiSummaryLoading(true);
|
||||
fetch('/api/ai/news/summary')
|
||||
.then(r => r.json())
|
||||
.then(d => { setAiSummary(d); setAiSummaryLoading(false); })
|
||||
.catch(() => setAiSummaryLoading(false));
|
||||
}
|
||||
}}
|
||||
className={`p-0.5 rounded-sm transition-colors ${
|
||||
aiSummaryOpen
|
||||
? 'text-purple-400 bg-purple-900/30 border border-purple-700/40'
|
||||
: 'text-gray-600 hover:text-purple-400 border border-transparent hover:border-purple-700/30'
|
||||
}`}
|
||||
title="AI Intelligence Brief"
|
||||
>
|
||||
<Brain size={14} />
|
||||
</button>
|
||||
{isMinimized ? (
|
||||
<Plus size={16} className="text-cyan-400" />
|
||||
) : (
|
||||
<Minus size={16} className="text-cyan-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
@@ -1104,22 +1368,108 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
data.threat_level.level === 'SEVERE' || data.threat_level.level === 'HIGH' ? 'animate-pulse' : ''
|
||||
}`} style={{ backgroundColor: data.threat_level.color }} />
|
||||
<span className="text-[9px] font-bold tracking-wider" style={{ color: data.threat_level.color }}>
|
||||
<span className="text-[12px] font-bold tracking-wider" style={{ color: data.threat_level.color }}>
|
||||
THREAT: {data.threat_level.level}
|
||||
</span>
|
||||
<span className="text-[9px] text-[var(--text-muted)] ml-auto">
|
||||
<span className="text-[12px] text-[var(--text-muted)] ml-auto">
|
||||
{data.threat_level.score}/100
|
||||
</span>
|
||||
</div>
|
||||
{data.threat_level.drivers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1 mb-1">
|
||||
{data.threat_level.drivers.map((d: string, i: number) => (
|
||||
<span key={i} className="text-[7px] px-1 py-0.5 bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-[var(--text-muted)] rounded-sm">
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
{/* Threat drivers removed — the level bar is sufficient */}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* AI Intelligence Brief */}
|
||||
<AnimatePresence>
|
||||
{!isMinimized && aiSummaryOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="px-3 pt-1 pb-2 overflow-hidden"
|
||||
>
|
||||
<div className="border border-purple-500/30 bg-purple-950/10 rounded-sm">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-purple-500/20">
|
||||
<Brain size={12} className="text-purple-400" />
|
||||
<span className="text-[11px] font-bold tracking-wider text-purple-400">AI INTELLIGENCE BRIEF</span>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse ml-auto" />
|
||||
</div>
|
||||
)}
|
||||
{aiSummaryLoading ? (
|
||||
<div className="p-3 flex items-center gap-2 text-[10px] text-purple-300">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
COMPILING INTELLIGENCE BRIEF...
|
||||
</div>
|
||||
) : aiSummary ? (
|
||||
<div className="p-2 flex flex-col gap-2 text-[10px]">
|
||||
<div className="text-purple-200 font-mono leading-relaxed">
|
||||
{aiSummary.summary}
|
||||
</div>
|
||||
{aiSummary.top_stories?.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] text-purple-400 tracking-widest font-bold mb-1">TOP STORIES</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{aiSummary.top_stories.slice(0, 5).map((s: any, i: number) => (
|
||||
<a key={i} href={s.link} target="_blank" rel="noreferrer" className="text-[11px] text-purple-200/80 hover:text-white transition-colors truncate">
|
||||
<span className={`mr-1 ${
|
||||
s.risk_score >= 9 ? 'text-red-400' :
|
||||
s.risk_score >= 7 ? 'text-orange-400' :
|
||||
s.risk_score >= 4 ? 'text-yellow-400' : 'text-green-400'
|
||||
}`}>●</span>
|
||||
[{s.risk_score}/10] {s.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{aiSummary.keywords?.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] text-purple-400 tracking-widest font-bold mb-1">TRENDING KEYWORDS</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{aiSummary.keywords.slice(0, 10).map((kw: any, i: number) => (
|
||||
<span key={i} className="text-[10px] px-1 py-0.5 bg-purple-950/50 border border-purple-500/20 text-purple-300 rounded-sm">
|
||||
{kw.word} ({kw.count})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{aiSummary.threat_distribution && (
|
||||
<div>
|
||||
<div className="text-[11px] text-purple-400 tracking-widest font-bold mb-1">THREAT BREAKDOWN</div>
|
||||
<div className="flex gap-1">
|
||||
{Object.entries(aiSummary.threat_distribution).map(([level, count]) => (
|
||||
<span key={level} className={`text-[10px] px-1.5 py-0.5 border rounded-sm font-bold ${
|
||||
level === 'CRITICAL' ? 'bg-red-950/40 border-red-500/30 text-red-400' :
|
||||
level === 'HIGH' ? 'bg-orange-950/40 border-orange-500/30 text-orange-400' :
|
||||
level === 'ELEVATED' ? 'bg-yellow-950/40 border-yellow-500/30 text-yellow-400' :
|
||||
level === 'MODERATE' ? 'bg-blue-950/40 border-blue-500/30 text-blue-400' :
|
||||
'bg-green-950/40 border-green-500/30 text-green-400'
|
||||
}`}>
|
||||
{level}: {count as number}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setAiSummaryLoading(true);
|
||||
setAiSummary(null);
|
||||
fetch('/api/ai/news/summary')
|
||||
.then(r => r.json())
|
||||
.then(d => { setAiSummary(d); setAiSummaryLoading(false); })
|
||||
.catch(() => setAiSummaryLoading(false));
|
||||
}}
|
||||
className="text-[11px] text-purple-500 hover:text-purple-300 transition-colors self-end"
|
||||
>
|
||||
↻ REFRESH BRIEF
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 text-[10px] text-purple-300/50">No data available.</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -1145,7 +1495,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
fimi.major_wave ? 'bg-amber-400 animate-pulse' : 'bg-purple-400'
|
||||
}`} />
|
||||
<span className={`text-[9px] font-bold tracking-wider ${
|
||||
<span className={`text-[11px] font-bold tracking-wider ${
|
||||
fimi.major_wave ? 'text-amber-400' : 'text-purple-400'
|
||||
}`}>
|
||||
{fimi.major_wave
|
||||
@@ -1153,14 +1503,14 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
: '⚠ DISINFORMATION INDEX'
|
||||
}
|
||||
</span>
|
||||
<span className="text-[8px] text-[var(--text-muted)] ml-auto flex items-center gap-1">
|
||||
<span className="text-[11px] text-[var(--text-muted)] ml-auto flex items-center gap-1">
|
||||
{Object.keys(fimi.threat_actors).length > 0 && (
|
||||
<span className="text-red-400">
|
||||
{Object.keys(fimi.threat_actors)[0]}
|
||||
</span>
|
||||
)}
|
||||
<span>{fimi.narratives.length} NARR</span>
|
||||
{fimiExpanded ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
|
||||
{fimiExpanded ? <Minus size={10} /> : <Plus size={10} />}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1176,7 +1526,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
{/* Threat Actor Bar */}
|
||||
{Object.keys(fimi.threat_actors).length > 0 && (
|
||||
<div className="px-2 py-1.5 border-b border-purple-500/10">
|
||||
<div className="text-[8px] text-purple-400 tracking-widest font-bold mb-1">THREAT ACTORS</div>
|
||||
<div className="text-[11px] text-purple-400 tracking-widest font-bold mb-1">THREAT ACTORS</div>
|
||||
<div className="flex gap-1 h-2 rounded-sm overflow-hidden">
|
||||
{(() => {
|
||||
const total = Object.values(fimi.threat_actors).reduce((a, b) => a + b, 0);
|
||||
@@ -1197,7 +1547,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
</div>
|
||||
<div className="flex gap-2 mt-1 flex-wrap">
|
||||
{Object.entries(fimi.threat_actors).map(([actor, count]) => (
|
||||
<span key={actor} className="text-[7px] text-[var(--text-muted)]">
|
||||
<span key={actor} className="text-[10px] text-[var(--text-muted)]">
|
||||
<span className={`font-bold ${
|
||||
actor === 'Russia' ? 'text-red-400' :
|
||||
actor === 'China' ? 'text-amber-400' :
|
||||
@@ -1212,7 +1562,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
|
||||
{/* Top Narratives */}
|
||||
<div className="px-2 py-1.5 border-b border-purple-500/10">
|
||||
<div className="text-[8px] text-purple-400 tracking-widest font-bold mb-1">LATEST NARRATIVES</div>
|
||||
<div className="text-[11px] text-purple-400 tracking-widest font-bold mb-1">LATEST NARRATIVES</div>
|
||||
<div className="flex flex-col gap-1 max-h-[120px] overflow-y-auto styled-scrollbar">
|
||||
{fimi.narratives.slice(0, 5).map((n, i) => (
|
||||
<a
|
||||
@@ -1220,7 +1570,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
href={n.link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[9px] text-[var(--text-secondary)] hover:text-purple-300 transition-colors leading-tight flex items-start gap-1"
|
||||
className="text-[11px] text-[var(--text-secondary)] hover:text-purple-300 transition-colors leading-tight flex items-start gap-1"
|
||||
>
|
||||
<ExternalLink size={8} className="text-purple-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="flex-1">{n.title}</span>
|
||||
@@ -1232,7 +1582,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
{/* Debunked Claims */}
|
||||
{fimi.claims.length > 0 && (
|
||||
<div className="px-2 py-1.5 border-b border-purple-500/10">
|
||||
<div className="text-[8px] text-red-400 tracking-widest font-bold mb-1">DEBUNKED CLAIMS ({fimi.claims.length})</div>
|
||||
<div className="text-[11px] text-red-400 tracking-widest font-bold mb-1">DEBUNKED CLAIMS ({fimi.claims.length})</div>
|
||||
<div className="flex flex-col gap-0.5 max-h-[80px] overflow-y-auto styled-scrollbar">
|
||||
{fimi.claims.slice(0, 5).map((c, i) => (
|
||||
<a
|
||||
@@ -1240,7 +1590,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
href={c.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[8px] text-red-300/70 hover:text-red-300 transition-colors truncate"
|
||||
className="text-[11px] text-red-300/70 hover:text-red-300 transition-colors truncate"
|
||||
>
|
||||
✕ {c.title}
|
||||
</a>
|
||||
@@ -1252,10 +1602,10 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
{/* Target Countries */}
|
||||
{Object.keys(fimi.targets).length > 0 && (
|
||||
<div className="px-2 py-1.5">
|
||||
<div className="text-[8px] text-purple-400 tracking-widest font-bold mb-1">TARGETS</div>
|
||||
<div className="text-[11px] text-purple-400 tracking-widest font-bold mb-1">TARGETS</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(fimi.targets).slice(0, 10).map(([target, count]) => (
|
||||
<span key={target} className="text-[7px] px-1 py-0.5 bg-purple-950/50 border border-purple-500/20 text-purple-300 rounded-sm">
|
||||
<span key={target} className="text-[10px] px-1 py-0.5 bg-purple-950/50 border border-purple-500/20 text-purple-300 rounded-sm">
|
||||
{target} ({count})
|
||||
</span>
|
||||
))}
|
||||
@@ -1265,10 +1615,10 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
|
||||
{/* Source attribution */}
|
||||
<div className="px-2 py-1 border-t border-purple-500/10 flex justify-between items-center">
|
||||
<a href={fimi.source_url} target="_blank" rel="noreferrer" className="text-[7px] text-purple-500 hover:text-purple-300 transition-colors">
|
||||
<a href={fimi.source_url} target="_blank" rel="noreferrer" className="text-[10px] text-purple-500 hover:text-purple-300 transition-colors">
|
||||
Source: {fimi.source}
|
||||
</a>
|
||||
<span className="text-[7px] text-[var(--text-muted)]">
|
||||
<span className="text-[10px] text-[var(--text-muted)]">
|
||||
{fimi.last_fetched ? new Date(fimi.last_fetched).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1322,7 +1672,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
transition={idx < 15 ? { delay: 0.1 + (idx * 0.05) } : { duration: 0 }}
|
||||
className={`p-2 rounded-sm border-l-[2px] border-r border-t border-b ${bgClass} flex flex-col gap-1 relative group shrink-0`}
|
||||
>
|
||||
<div className="flex items-center justify-between text-[8px] text-[var(--text-secondary)] uppercase tracking-widest">
|
||||
<div className="flex items-center justify-between text-[12px] text-[var(--text-secondary)] uppercase tracking-widest">
|
||||
<span className="font-bold flex items-center gap-1 text-white">
|
||||
{isBreaking && <span className="text-red-400 mr-1">BREAKING</span>}
|
||||
>_ {item.source}
|
||||
@@ -1330,22 +1680,22 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
<span>[{item.published ? formatTime(item.published) : ''}]</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onArticleClick?.(idx, item.coords?.[0], item.coords?.[1])}
|
||||
className={`text-left text-[11px] ${titleClass} hover:text-[var(--text-primary)] transition-colors leading-tight cursor-pointer`}
|
||||
<button
|
||||
onClick={() => onArticleClick?.(idx, item.coords?.[0], item.coords?.[1], item.title)}
|
||||
className={`text-left text-[12px] ${titleClass} hover:text-[var(--text-primary)] transition-colors leading-tight cursor-pointer`}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
|
||||
{item.machine_assessment && (
|
||||
<div className="mt-1 p-1.5 bg-black/60 border border-cyan-800/50 rounded-sm text-[8.5px] text-cyan-400 font-mono leading-tight relative overflow-hidden shadow-[inset_0_0_10px_rgba(0,255,255,0.05)]">
|
||||
<div className="mt-1 p-1.5 bg-black/60 border border-cyan-800/50 rounded-sm text-[11px] text-cyan-400 font-mono leading-tight relative overflow-hidden shadow-[inset_0_0_10px_rgba(0,255,255,0.05)]">
|
||||
<div className="absolute top-0 left-0 w-[2px] h-full bg-cyan-500 animate-pulse"></div>
|
||||
<span className="font-bold text-white">>_ SYS.ANALYSIS: </span>
|
||||
<span className="text-cyan-300 opacity-90">{item.machine_assessment}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.prediction_odds && item.prediction_odds.consensus_pct != null && (
|
||||
<div className="mt-1 px-1.5 py-1 bg-purple-950/30 border border-purple-500/30 rounded-sm text-[8px] font-mono flex items-center gap-1.5">
|
||||
<div className="mt-1 px-1.5 py-1 bg-purple-950/30 border border-purple-500/30 rounded-sm text-[11px] font-mono flex items-center gap-1.5">
|
||||
<span className="text-purple-400 font-bold">MKT</span>
|
||||
<span className="text-purple-300 truncate flex-1" title={item.prediction_odds.title}>{item.prediction_odds.title}</span>
|
||||
<span className="text-white font-bold whitespace-nowrap">{item.prediction_odds.consensus_pct}%</span>
|
||||
@@ -1353,11 +1703,11 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 mt-1 relative z-10 flex-wrap">
|
||||
<span className={`text-[8px] font-bold font-mono px-1.5 py-0.5 rounded-sm border ${badgeClass}`}>
|
||||
<span className={`text-[11px] font-bold font-mono px-1.5 py-0.5 rounded-sm border ${badgeClass}`}>
|
||||
{isBreaking ? 'BREAKING' : `LVL: ${item.risk_score}/10`}
|
||||
</span>
|
||||
{item.sentiment != null && (
|
||||
<span className={`text-[8px] font-bold font-mono px-1.5 py-0.5 rounded-sm border ${
|
||||
<span className={`text-[11px] font-bold font-mono px-1.5 py-0.5 rounded-sm border ${
|
||||
item.sentiment < -0.1 ? 'bg-red-500/10 text-red-400 border-red-500/30' :
|
||||
item.sentiment > 0.1 ? 'bg-green-500/10 text-green-400 border-green-500/30' :
|
||||
'bg-gray-500/10 text-gray-400 border-gray-500/30'
|
||||
@@ -1367,7 +1717,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
</span>
|
||||
)}
|
||||
{item.oracle_score != null && (
|
||||
<span className={`text-[8px] font-bold font-mono px-1.5 py-0.5 rounded-sm border ${
|
||||
<span className={`text-[11px] font-bold font-mono px-1.5 py-0.5 rounded-sm border ${
|
||||
item.oracle_score >= 7 ? 'bg-orange-500/10 text-orange-400 border-orange-500/30' :
|
||||
item.oracle_score >= 4 ? 'bg-yellow-500/10 text-yellow-400 border-yellow-500/30' :
|
||||
'bg-cyan-500/10 text-cyan-400 border-cyan-500/30'
|
||||
@@ -1376,17 +1726,17 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
</span>
|
||||
)}
|
||||
{checkDisinfoLinked(item.title) && (
|
||||
<span className="text-[8px] font-bold font-mono px-1.5 py-0.5 rounded-sm border bg-amber-500/15 text-amber-400 border-amber-500/40 animate-pulse" title="This article echoes known disinformation narratives tracked by EUvsDisinfo">
|
||||
<span className="text-[11px] font-bold font-mono px-1.5 py-0.5 rounded-sm border bg-amber-500/15 text-amber-400 border-amber-500/40 animate-pulse" title="This article echoes known disinformation narratives tracked by EUvsDisinfo">
|
||||
⚠ DISINFORMATION-LINKED
|
||||
</span>
|
||||
)}
|
||||
{item.cluster_count > 1 && (
|
||||
<button onClick={() => toggleExpand(idx)} className="text-[8px] font-bold font-mono text-cyan-500 bg-[var(--bg-secondary)]/50 hover:text-[var(--text-primary)] hover:bg-[var(--hover-accent)] border border-cyan-500/30 px-1.5 py-0.5 rounded-sm transition-colors cursor-pointer">
|
||||
<button onClick={() => toggleExpand(idx)} className="text-[11px] font-bold font-mono text-cyan-500 bg-[var(--bg-secondary)]/50 hover:text-[var(--text-primary)] hover:bg-[var(--hover-accent)] border border-cyan-500/30 px-1.5 py-0.5 rounded-sm transition-colors cursor-pointer">
|
||||
{isExpanded ? '- COLLAPSE' : `+${item.cluster_count - 1} SOURCES`}
|
||||
</button>
|
||||
)}
|
||||
{item.coords && (
|
||||
<span className="text-[8px] text-[var(--text-muted)] font-mono tracking-tighter ml-auto">
|
||||
<span className="text-[11px] text-[var(--text-muted)] font-mono tracking-tighter ml-auto">
|
||||
{item.coords[0].toFixed(2)}, {item.coords[1].toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
@@ -1402,7 +1752,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
>
|
||||
{item.articles.slice(1).map((subItem: any, subIdx: number) => (
|
||||
<div key={subIdx} className="flex flex-col gap-0.5 pl-2 border-l border-cyan-500/20">
|
||||
<div className="flex items-center justify-between text-[7.5px] uppercase font-bold">
|
||||
<div className="flex items-center justify-between text-[11px] uppercase font-bold">
|
||||
<span className="text-white">>_ {subItem.source}</span>
|
||||
<span className={
|
||||
subItem.risk_score >= 9 ? 'text-red-400' :
|
||||
@@ -1411,7 +1761,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
'text-green-400'
|
||||
}>LVL: {subItem.risk_score}/10</span>
|
||||
</div>
|
||||
<a href={subItem.link} target="_blank" rel="noreferrer" className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors leading-tight">
|
||||
<a href={subItem.link} target="_blank" rel="noreferrer" className="text-[11px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors leading-tight">
|
||||
{subItem.title}
|
||||
</a>
|
||||
</div>
|
||||
@@ -1425,7 +1775,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
|
||||
{news.length === 0 && (
|
||||
<div className="text-cyan-500/50 text-[10px] tracking-widest font-bold text-center mt-6">
|
||||
NO NEWS ITEMS LOADED
|
||||
<div className="mt-2 text-[9px] font-normal tracking-normal text-cyan-600/80">
|
||||
<div className="mt-2 text-[11px] font-normal tracking-normal text-cyan-600/80">
|
||||
Feed ingest is empty or still warming up.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -199,11 +199,11 @@ const OnboardingModal = React.memo(function OnboardingModal({
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-yellow-300">PRIVATE / TRANSITIONAL</span> —
|
||||
Wormhole private lane is active, but strongest Reticulum posture is still warming.
|
||||
Wormhole lane is active. Gate chat runs on this lane, but metadata resistance is reduced until Reticulum is ready.
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-green-300">PRIVATE / STRONG</span> — Wormhole and
|
||||
Reticulum are both ready.
|
||||
Reticulum are both ready. Dead Drop / DM requires this tier for the strongest privacy posture.
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user