diff --git a/meshexplorer/src/components/ChatMessageItem.tsx b/meshexplorer/src/components/ChatMessageItem.tsx
index 01ed42e..3dc039a 100644
--- a/meshexplorer/src/components/ChatMessageItem.tsx
+++ b/meshexplorer/src/components/ChatMessageItem.tsx
@@ -6,6 +6,7 @@ import PathVisualization from "./PathVisualization";
import { PathData } from "@/lib/pathUtils";
import NodeLinkWithHover from "./NodeLinkWithHover";
import { findNodeMentions } from "@/lib/node-utils";
+import { isHiddenNodeName } from "@/lib/node-privacy";
export interface ChatMessage {
message_id: string;
@@ -140,6 +141,10 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow
if (parsed) {
+ // Node privacy: a sender that opted out via its name has its message hidden entirely.
+ if (isHiddenNodeName(parsed.sender)) {
+ return null;
+ }
return (
diff --git a/meshexplorer/src/lib/clickhouse/actions.ts b/meshexplorer/src/lib/clickhouse/actions.ts
index 4b77f99..2bdbb12 100644
--- a/meshexplorer/src/lib/clickhouse/actions.ts
+++ b/meshexplorer/src/lib/clickhouse/actions.ts
@@ -3,6 +3,7 @@ import { clickhouse } from "./clickhouse";
import { generateRegionWhereClauseFromArray, generateRegionWhereClause } from "@/lib/regionFilters";
import { regionFromTopic, normalizeRegion, groupCodeOf } from "@/lib/regions";
import { publicChannelMessagesSubquery } from "./chatMessages";
+import { isHiddenNodeName, visibleNodeSqlClause } from "@/lib/node-privacy";
export async function getNodePositions({ minLat, maxLat, minLng, maxLng, nodeTypes, lastSeen }: { minLat?: string | null, maxLat?: string | null, minLng?: string | null, maxLng?: string | null, nodeTypes?: string[], lastSeen?: string | null } = {}) {
try {
@@ -10,7 +11,9 @@ export async function getNodePositions({ minLat, maxLat, minLng, maxLng, nodeTyp
"latitude IS NOT NULL",
"longitude IS NOT NULL",
// Exclude the (0,0) "null island" sentinel (location bit set but no real GPS fix)
- "(abs(latitude) > 0.01 OR abs(longitude) > 0.01)"
+ "(abs(latitude) > 0.01 OR abs(longitude) > 0.01)",
+ // Node privacy: hide nodes whose name carries an opt-out emoji.
+ visibleNodeSqlClause("name")
];
const params: Record = {};
if (minLat !== null && minLat !== undefined && minLat !== "") {
@@ -166,7 +169,12 @@ export async function getMeshcoreNodeInfo(publicKey: string, limit: number = 50)
if (!nodeInfo || nodeInfo.length === 0) {
return null;
}
-
+
+ // Node privacy: a node that opted out via its name is treated as not found.
+ if (isHiddenNodeName(nodeInfo[0].node_name)) {
+ return null;
+ }
+
// Get recent adverts grouped by adv_timestamp with origin_path_pubkey tuples
const advertsQuery = `
SELECT
@@ -316,6 +324,9 @@ export async function getAllNodeNeighbors(lastSeen: string | null = null, minLat
"source_longitude IS NOT NULL",
"target_latitude IS NOT NULL",
"target_longitude IS NOT NULL",
+ // Node privacy: drop the edge if either endpoint opted out via its name.
+ visibleNodeSqlClause("source_name"),
+ visibleNodeSqlClause("target_name"),
];
// Bounding box: both endpoints must be within view (matches the old visible_nodes behavior)
@@ -391,7 +402,11 @@ export async function getMeshcoreNodeNeighbors(publicKey: string, lastSeen: stri
// Reads the precomputed (hourly-refreshed) per-node direct adjacency from the
// refreshable materialized view meshcore_node_direct_neighbors.
const params: Record = { publicKey };
- const whereConditions = ["node_public_key = {publicKey:String}"];
+ const whereConditions = [
+ "node_public_key = {publicKey:String}",
+ // Node privacy: hide neighbors that opted out via their name.
+ visibleNodeSqlClause("neighbor_name"),
+ ];
if (lastSeen !== null) {
whereConditions.push("neighbor_last_seen >= now() - INTERVAL {lastSeen:UInt32} SECOND");
params.lastSeen = Number(lastSeen);
@@ -473,7 +488,8 @@ export async function searchMeshcoreNodes(searchParams: SearchQuery | SearchQuer
is_repeater
} = searchQuery;
- const where: string[] = [];
+ // Node privacy: hidden nodes are unsearchable.
+ const where: string[] = [visibleNodeSqlClause("node_name")];
const queryParams: Record = {};
// Add search conditions
diff --git a/meshexplorer/src/lib/node-privacy.ts b/meshexplorer/src/lib/node-privacy.ts
new file mode 100644
index 0000000..f3774ec
--- /dev/null
+++ b/meshexplorer/src/lib/node-privacy.ts
@@ -0,0 +1,27 @@
+/**
+ * Node privacy: operators can opt a node out of the public-facing surfaces of
+ * meshexplorer (map, neighbors, chat, search, detail page) by putting any of the
+ * "hidden" emoji in the node name.
+ *
+ * Match on the base codepoints below, ignoring the optional variation selector
+ * (⛔️ = ⛔ + U+FE0F): a substring / position() check on the base codepoint matches
+ * whether or not the VS-16 is present, so we never special-case it.
+ */
+
+// Base codepoints only — the VS-16 (U+FE0F) variant of ⛔ is matched by substring.
+export const HIDDEN_NODE_EMOJIS = ["⛔", "🛑", "🚫"] as const;
+
+/** JS check used client-side (chat) and server-side (node detail). */
+export function isHiddenNodeName(name?: string | null): boolean {
+ if (!name) return false;
+ return HIDDEN_NODE_EMOJIS.some((e) => name.includes(e));
+}
+
+/**
+ * SQL predicate that is TRUE for VISIBLE (non-hidden) nodes, for pushing into a
+ * WHERE list. `column` is a trusted column identifier (never user input); the
+ * emoji are our own constants, so inlining them as literals is safe.
+ */
+export function visibleNodeSqlClause(column: string): string {
+ return HIDDEN_NODE_EMOJIS.map((e) => `position(${column}, '${e}') = 0`).join(" AND ");
+}