mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-08 05:17:49 +02:00
Ship ShadowBroker v0.9.83 with live Infonet gate messaging and DM protocols.
Gate hashchain replication, Tor/SOCKS transport hardening, terminal session teardown, v0.9.83 UI/changelog, and release digest pins for seamless updater verification.
This commit is contained in:
@@ -4,101 +4,92 @@ import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
X,
|
||||
Bot,
|
||||
Network,
|
||||
KeyRound,
|
||||
Shield,
|
||||
Plane,
|
||||
Bug,
|
||||
Heart,
|
||||
MessageSquare,
|
||||
Radar,
|
||||
Factory,
|
||||
Anchor,
|
||||
Search,
|
||||
Lock,
|
||||
Users,
|
||||
Radio,
|
||||
} from 'lucide-react';
|
||||
|
||||
const CURRENT_VERSION = '0.9.82';
|
||||
const CURRENT_VERSION = '0.9.83';
|
||||
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
|
||||
const RELEASE_TITLE = 'Telegram OSINT + Osiris Intel Ports + OpenClaw Recon';
|
||||
const RELEASE_TITLE = 'Infonet Gate Messaging + DM Protocols Live';
|
||||
|
||||
const HEADLINE_FEATURES = [
|
||||
{
|
||||
icon: <Lock size={20} className="text-purple-400" />,
|
||||
accent: 'purple' as const,
|
||||
title: 'Gate Messaging — End-to-End on the Hashchain',
|
||||
subtitle:
|
||||
'Encrypted MLS gate rooms now carry live chat over your private Infonet hashchain. Messages replicate across participant nodes via swarm push/pull — only gate members can decrypt.',
|
||||
details: [
|
||||
'Gate messages append as signed `gate_message` events on each participant\'s private chain; peers sync ciphertext through the mesh without exposing room keys to outsiders.',
|
||||
'Swarm replication keeps late joiners and offline nodes convergent — pull missing blocks, push new envelopes to known gate peers.',
|
||||
'MLS group crypto (privacy-core) handles forward secrecy and membership changes; the UI surfaces delivery, key rotation, and compat approval when room epochs advance.',
|
||||
],
|
||||
callToAction: 'MESH CHAT → GATES → CREATE OR JOIN A ROOM',
|
||||
},
|
||||
{
|
||||
icon: <MessageSquare size={20} className="text-cyan-400" />,
|
||||
accent: 'cyan' as const,
|
||||
title: 'Telegram OSINT Map Layer',
|
||||
title: 'Direct Messages — Short Address, Request, Encrypt',
|
||||
subtitle:
|
||||
'Public war/conflict Telegram channels scraped hourly, risk-scored, geoparsed to metro anchors, and plotted as clickable map pins with inline photo/video.',
|
||||
'DMs are fully operational over the wormhole/Tor lane: share your short wormhole address out-of-band, accept a contact request, then exchange ratchet-encrypted messages.',
|
||||
details: [
|
||||
'Incremental merge — only new posts are fetched; known links stop the parser early so channels are not re-scraped redundantly.',
|
||||
'Metro-anchor geocoding (Tel Aviv, Kyiv, NYC, Beijing, etc.) keeps Telegram pins off news/threat-alert centroids so pins stay clickable above the threat intercept overlays.',
|
||||
'Threat-intercept styled popups with inline media via `/api/telegram/media` proxy. Configure channels via `TELEGRAM_OSINT_CHANNELS` (see `.env.example`).',
|
||||
'Contact flow: outbound request → peer approve/deny → mutual DM session with double-ratchet bundles and mailbox claim keys.',
|
||||
'No public phonebook — addresses are intentionally short and meant to be exchanged like a phone number or email, not discovered from a directory.',
|
||||
'Fleet-tested across multiple onion participants: request, accept, decrypt, and reply paths verified on live Tor hidden services.',
|
||||
],
|
||||
callToAction: 'TOGGLE TELEGRAM OSINT IN DATA LAYERS',
|
||||
callToAction: 'MESH CHAT → DIRECT → SHARE SHORT ADDRESS',
|
||||
},
|
||||
{
|
||||
icon: <Radar size={20} className="text-purple-400" />,
|
||||
accent: 'purple' as const,
|
||||
title: 'Osiris-Derived Intel Ports (Recon, SCM, Entity Graph)',
|
||||
subtitle:
|
||||
'Server-side recon toolkit, supply-chain risk overlay, entity relationship graphs, malware/C2 hotspots, CISA KEV cyber feed, sanctions index, and submarine cable routes — all SSRF-guarded and local-operator proxied.',
|
||||
details: [
|
||||
'Recon Toolkit panel: IP geolocation, DNS, WHOIS, certs, BGP/ASN, OFAC sanctions, CVE, MAC vendor, GitHub profile, breach checks, and InternetDB subnet sweeps.',
|
||||
'SCM panel cross-references Tier 1/2 fabs (TSMC, Samsung, CATL, etc.) against earthquakes, wildfires, and GDELT conflict proximity.',
|
||||
'Entity Graph expands aircraft, vessels, companies, persons, IPs, and countries via Wikidata + OFAC + live telemetry store. Attribution: `backend/third_party/osiris/NOTICE.md`.',
|
||||
'Malware C2 (abuse.ch Feodo + URLhaus) and Cyber Threats (CISA KEV) layers opt-in on the slow tier. Submarine cables overlay from static TeleGeography-derived GeoJSON.',
|
||||
],
|
||||
callToAction: 'OPEN RECON • SCM • ENTITY GRAPH IN LEFT SIDEBAR',
|
||||
},
|
||||
{
|
||||
icon: <Bot size={20} className="text-amber-400" />,
|
||||
icon: <Network size={20} className="text-amber-400" />,
|
||||
accent: 'cyan' as const,
|
||||
title: 'OpenClaw Agent — Full Telemetry + Recon Parity',
|
||||
title: 'Infonet Transport Hardening',
|
||||
subtitle:
|
||||
'AI agents on the HMAC command channel now search, slice, and investigate the same data the operator sees — including Telegram, malware, cyber, SCM, and the full recon toolkit.',
|
||||
'Tor/Arti warmup, SOCKS readiness, and terminal session lifecycle fixes so sovereign nodes actually join the mesh instead of sitting on NODE ARTI WARMING.',
|
||||
details: [
|
||||
'`search_telemetry` and `search_news` index Telegram OSINT posts alongside news, GDELT, and CrowdThreat. `get_slow_telemetry` and `get_layer_slice` include `telegram_osint`, `malware_threats`, `cyber_threats`, and `scm_suppliers`.',
|
||||
'New commands: `osint_lookup` (IP/DNS/WHOIS/sanctions/CVE/etc.), `entity_expand` (relationship graph), `osint_tools` (discovery), and `osint_sweep` (subnet scan — full access tier).',
|
||||
'Layer aliases: `telegram`, `malware`/`botnet`, `cyber`/`cisa`/`kev`, `scm`/`suppliers`, `gfw`/`fishing`. Skill package: `openclaw-skills/shadowbroker/SKILL.md`.',
|
||||
'Tor hidden service config always exposes SOCKS; readiness probes cache and recover wedged transports instead of blocking wormhole sync indefinitely.',
|
||||
'Leaving the Infonet terminal now tears down wormhole prep, leaves the session, and stops Tor when the UI enabled it — no ghost connections after close.',
|
||||
'Network stats distinguish real transport warmup from stale sync backoff so operators see actionable status instead of a permanent warming spinner.',
|
||||
],
|
||||
callToAction: 'AI INTEL PANEL → CONNECT AGENT → COPY HMAC SECRET',
|
||||
callToAction: 'TOP RIGHT → ENTER INFONET → CHECK NODE STATUS',
|
||||
},
|
||||
];
|
||||
|
||||
const NEW_FEATURES = [
|
||||
{
|
||||
icon: <Anchor size={18} className="text-cyan-400" />,
|
||||
title: 'Global Fishing Watch in Settings',
|
||||
desc: 'GFW API token exposed in onboarding and Settings → Maritime. Fishing activity layer backed by GFW when `GFW_API_TOKEN` is configured.',
|
||||
icon: <Users size={18} className="text-purple-400" />,
|
||||
title: 'Gate Swarm Replication',
|
||||
desc: 'Participant nodes push and pull gate hashchain segments so encrypted room history converges across the fleet without a central relay.',
|
||||
},
|
||||
{
|
||||
icon: <Factory size={18} className="text-orange-400" />,
|
||||
title: 'Supply-Chain Risk Map Layer',
|
||||
desc: 'SCM suppliers render as map markers with seismic, wildfire, and conflict proximity scoring. Panel alerts for CRITICAL/HIGH fabs.',
|
||||
icon: <KeyRound size={18} className="text-cyan-400" />,
|
||||
title: 'DM Contact Requests',
|
||||
desc: 'Pending inbound/outbound access requests with approve, deny, and scoped per-node DM state — no cross-identity leakage in local storage.',
|
||||
},
|
||||
{
|
||||
icon: <Shield size={18} className="text-red-400" />,
|
||||
title: 'Malware C2 + CISA KEV Overlays',
|
||||
desc: 'abuse.ch botnet C2 and URLhaus distribution URLs geolocated by country; CISA Known Exploited Vulnerabilities surfaced in cyber threats feed and slow-tier payload.',
|
||||
icon: <Radio size={18} className="text-green-400" />,
|
||||
title: 'Wormhole Session Teardown',
|
||||
desc: 'Closing the Infonet terminal aborts in-flight wormhole prep, leaves the lane, and resets launcher busy state for clean re-entry.',
|
||||
},
|
||||
{
|
||||
icon: <Search size={18} className="text-green-400" />,
|
||||
title: 'OpenClaw Compact Search Path',
|
||||
desc: 'Agents prefer `get_summary` → SSE `layer_changed` → `get_layer_slice` with per-layer versions. `brief_area`, `correlate_entity`, and `entities_near` include Telegram and malware context.',
|
||||
},
|
||||
{
|
||||
icon: <Network size={18} className="text-purple-400" />,
|
||||
title: 'Submarine Cable Overlay',
|
||||
desc: 'Opt-in undersea cable routes from static TeleGeography-derived GeoJSON for infrastructure context on the map.',
|
||||
icon: <Shield size={18} className="text-amber-400" />,
|
||||
title: 'Fail-Closed Tor Proof',
|
||||
desc: 'Onion sync waits for a working SOCKS handshake before declaring transport ready — prevents silent half-open mesh joins.',
|
||||
},
|
||||
];
|
||||
|
||||
const BUG_FIXES = [
|
||||
'Telegram map pins are now HTML markers above threat-alert overlays — pins are clickable even when sharing a city grid with news intercept boxes.',
|
||||
'Telegram geocoding uses metro anchors (not national centroids) and a small NE offset only when news alerts share the same city cell — pins stay on land.',
|
||||
'Hourly Telegram scheduler with incremental post merge — no redundant full-channel re-scrape every cycle.',
|
||||
'OpenClaw `get_slow_telemetry` previously omitted telegram_osint, malware_threats, cyber_threats, and scm_suppliers — now included in slow-tier and universal search.',
|
||||
'OpenClaw agents can invoke the Recon panel backends via `osint_lookup` without raw `/api/osint/*` HTTP calls or local-operator browser auth.',
|
||||
'Arti/Tor transport no longer omits SocksPort when MESH_ARTI_ENABLED — SOCKS probes succeed and wormhole sync can start.',
|
||||
'Concurrent Arti readiness checks no longer wedge Tor under load; single-flight probes with auto-recycle when SOCKS stalls.',
|
||||
'Infonet terminal exit no longer leaves background wormhole prep or terminalLaunchBusy stuck after close.',
|
||||
'Stale onion sync backoff clears when transport recovers so NODE ARTI WARMING does not persist after Tor is healthy.',
|
||||
'DM decrypt timeouts on multi-participant fleets addressed via improved peer push timing and mailbox claim sequencing.',
|
||||
];
|
||||
|
||||
type ChangelogContributor = {
|
||||
@@ -109,8 +100,8 @@ type ChangelogContributor = {
|
||||
|
||||
const CONTRIBUTORS: ChangelogContributor[] = [
|
||||
{
|
||||
name: 'OSIRIS (simplifaisoul/osiris)',
|
||||
desc: 'MIT-licensed recon stack — adapted for ShadowBroker proxy model (see backend/third_party/osiris/NOTICE.md)',
|
||||
name: 'privacy-core (MLS)',
|
||||
desc: 'Rust MLS gate crypto — WASM/FFI path for browser and Tauri sovereign shells',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -228,41 +219,18 @@ const ChangelogModal = React.memo(function ChangelogModal({ onClose }: Changelog
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Auto-update note for v0.9.81+ installs */}
|
||||
{/* Auto-update note for v0.9.82+ installs */}
|
||||
<div className="border border-green-500/30 bg-green-950/15 p-3 flex items-start gap-3">
|
||||
<KeyRound size={18} className="text-green-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-mono text-green-300 font-bold tracking-wide uppercase">
|
||||
One-click update from v0.9.81
|
||||
One-click update from v0.9.82
|
||||
</div>
|
||||
<div className="text-xs font-mono text-green-200/80 leading-relaxed">
|
||||
If you installed v0.9.81, the in-app Update button verifies this release via the
|
||||
signed Tauri updater (`latest.json` + minisign). Desktop installs on v0.9.81 or
|
||||
later should auto-apply v0.9.82 without a manual MSI hop.
|
||||
</div>
|
||||
</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">
|
||||
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"
|
||||
>
|
||||
opensky-network.org/register
|
||||
</a>
|
||||
.
|
||||
If you installed v0.9.82, the in-app Update button verifies this release via the
|
||||
signed Tauri updater (`latest.json` + minisign). Desktop installs on v0.9.82 or
|
||||
later should auto-apply v0.9.83 without a manual MSI hop once the release is
|
||||
published.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,13 +14,26 @@ interface Stats {
|
||||
nodeEnabled: boolean;
|
||||
syncOutcome: string;
|
||||
syncError: string;
|
||||
artiReady: boolean | null;
|
||||
}
|
||||
|
||||
const EMPTY: Stats = {
|
||||
meshtastic: 0, aprs: 0, ledgerNodes: 0, infonetEvents: 0,
|
||||
syncPeers: 0, seedPeers: 0, nodeEnabled: false, syncOutcome: 'offline', syncError: '',
|
||||
artiReady: null,
|
||||
};
|
||||
|
||||
function isArtiTransportBlocked(syncError: string, artiReady: boolean | null): boolean {
|
||||
if (artiReady === true) return false;
|
||||
if (artiReady === false) return true;
|
||||
const lower = syncError.toLowerCase();
|
||||
return (
|
||||
lower.includes('ready arti transport')
|
||||
|| lower.includes('require arti to be enabled')
|
||||
|| lower.includes('onion peer requests require a ready arti')
|
||||
);
|
||||
}
|
||||
|
||||
export default function NetworkStats() {
|
||||
const [stats, setStats] = useState<Stats>(EMPTY);
|
||||
|
||||
@@ -28,10 +41,11 @@ export default function NetworkStats() {
|
||||
let alive = true;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const [meshRes, channelsRes, infonet] = await Promise.all([
|
||||
const [meshRes, channelsRes, infonet, wormholeRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/mesh/status`).then(r => r.ok ? r.json() : null).catch(() => null),
|
||||
fetch(`${API_BASE}/api/mesh/channels`).then(r => r.ok ? r.json() : null).catch(() => null),
|
||||
fetchInfonetNodeStatusSnapshot(true).catch(() => null),
|
||||
fetch(`${API_BASE}/api/wormhole/status`).then(r => r.ok ? r.json() : null).catch(() => null),
|
||||
]);
|
||||
if (!alive) return;
|
||||
const authorNodes = Number(infonet?.author_nodes ?? infonet?.known_nodes ?? 0);
|
||||
@@ -43,6 +57,7 @@ export default function NetworkStats() {
|
||||
?? 0,
|
||||
);
|
||||
const syncOutcome = String(infonet?.sync_runtime?.last_outcome || 'offline').toLowerCase();
|
||||
const artiReady = typeof wormholeRes?.arti_ready === 'boolean' ? wormholeRes.arti_ready : null;
|
||||
setStats({
|
||||
meshtastic: Number(channelsRes?.total_live || channelsRes?.total_nodes || meshRes?.signal_counts?.meshtastic || 0),
|
||||
aprs: Number(meshRes?.signal_counts?.aprs || 0),
|
||||
@@ -53,6 +68,7 @@ export default function NetworkStats() {
|
||||
nodeEnabled: Boolean(infonet?.node_enabled),
|
||||
syncOutcome,
|
||||
syncError: String(infonet?.sync_runtime?.last_error || '').trim(),
|
||||
artiReady,
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
@@ -61,8 +77,7 @@ export default function NetworkStats() {
|
||||
return () => { alive = false; clearInterval(interval); };
|
||||
}, []);
|
||||
|
||||
const syncErrorLower = stats.syncError.toLowerCase();
|
||||
const artiBlocked = syncErrorLower.includes('arti') || syncErrorLower.includes('onion');
|
||||
const artiBlocked = isArtiTransportBlocked(stats.syncError, stats.artiReady);
|
||||
const nodeColor = stats.syncOutcome === 'ok' || stats.syncOutcome === 'solo' ? 'text-green-400'
|
||||
: stats.syncOutcome === 'running' ? 'text-amber-400'
|
||||
: stats.nodeEnabled ? 'text-amber-400' : 'text-gray-600';
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { X } from 'lucide-react';
|
||||
import {
|
||||
ensureInfonetParticipantNodeReady,
|
||||
} from '@/mesh/controlPlaneStatusClient';
|
||||
import { beginInfonetTerminalSession } from '@/lib/infonetTerminalSession';
|
||||
import InfonetShell from './InfonetShell';
|
||||
|
||||
interface InfonetTerminalProps {
|
||||
@@ -38,7 +36,7 @@ export default function InfonetTerminal({
|
||||
const connectParticipantNode = async () => {
|
||||
try {
|
||||
if (cancelled) return;
|
||||
await ensureInfonetParticipantNodeReady();
|
||||
await beginInfonetTerminalSession();
|
||||
} catch {
|
||||
// Remote/shared viewers may not have local-operator rights. Leave manual controls intact.
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { ensureInfonetParticipantNodeReady } from '@/mesh/controlPlaneStatusClient';
|
||||
import { beginInfonetTerminalSession } from '@/lib/infonetTerminalSession';
|
||||
import InfonetShell from '@/components/InfonetTerminal/InfonetShell';
|
||||
|
||||
type Props = {
|
||||
@@ -98,7 +98,7 @@ export default function InfonetTerminalPanel({
|
||||
const connectParticipantNode = async () => {
|
||||
try {
|
||||
if (cancelled) return;
|
||||
await ensureInfonetParticipantNodeReady();
|
||||
await beginInfonetTerminalSession();
|
||||
} catch {
|
||||
// Remote viewers may not have local-operator rights.
|
||||
}
|
||||
|
||||
@@ -11,8 +11,7 @@ import {
|
||||
SHELL_FLYOUT_WIDTH,
|
||||
type MeshChatFlyoutRect,
|
||||
} from './meshChatFlyout';
|
||||
import { fetchWormholeState, leaveWormhole } from '@/mesh/wormholeClient';
|
||||
import { teardownWormholeOnClose } from '@/lib/wormholeTeardown';
|
||||
import { endInfonetTerminalSession } from '@/lib/infonetTerminalSession';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Antenna,
|
||||
@@ -439,7 +438,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
}, []);
|
||||
|
||||
const handleInfonetTeardown = useCallback(() => {
|
||||
void teardownWormholeOnClose(fetchWormholeState, leaveWormhole);
|
||||
void endInfonetTerminalSession();
|
||||
}, []);
|
||||
|
||||
const panelFlyout =
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Database, Clock, X } from 'lucide-react';
|
||||
|
||||
const CURRENT_VERSION = '0.9.82';
|
||||
const CURRENT_VERSION = '0.9.83';
|
||||
const STORAGE_KEY = `shadowbroker_startup_warmup_notice_v${CURRENT_VERSION}`;
|
||||
|
||||
interface StartupWarmupModalProps {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import {
|
||||
requestMeshTerminalOpen,
|
||||
subscribeSecureMeshTerminalLauncherOpen,
|
||||
subscribeInfonetSessionEnd,
|
||||
} from '@/lib/meshTerminalLauncher';
|
||||
import { purgeBrowserContactGraph, purgeBrowserSigningMaterial, setSecureModeCached, getNodeIdentity, generateNodeKeys } from '@/mesh/meshIdentity';
|
||||
import { purgeBrowserDmState } from '@/mesh/meshDmWorkerClient';
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
import {
|
||||
fetchWormholeStatus,
|
||||
prepareWormholeInteractiveLane,
|
||||
isWormholePrepAbortedError,
|
||||
} from '@/mesh/wormholeIdentityClient';
|
||||
import { fetchWormholeSettings } from '@/mesh/wormholeClient';
|
||||
import packageJson from '../../package.json';
|
||||
@@ -172,8 +174,15 @@ export default function TopRightControls({
|
||||
});
|
||||
}, [openTerminalLauncher]);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeInfonetSessionEnd(() => {
|
||||
setTerminalLaunchBusy(false);
|
||||
setTerminalLaunchError('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeTerminalLauncher = () => {
|
||||
if (terminalLaunchBusy) return;
|
||||
setTerminalLaunchBusy(false);
|
||||
setTerminalLauncherOpen(false);
|
||||
setTerminalLaunchError('');
|
||||
};
|
||||
@@ -215,6 +224,9 @@ export default function TopRightControls({
|
||||
console.info('[top-right] Wormhole terminal launch ready', identityNodeId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isWormholePrepAbortedError(error)) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
typeof error === 'object' && error !== null && 'message' in error
|
||||
? String((error as { message?: string }).message || '')
|
||||
|
||||
Reference in New Issue
Block a user