Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion backend/routes/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
user_to_dict,
)
from services import publication
from services.node_refs import id_for_ref, public_identity, public_name
from services.node_refs import id_for_ref, public_identity, public_name, ref_to_id_map

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -352,6 +352,27 @@ async def admin_clear_node_location_privacy(node_id: str, admin=Depends(require_
}


@router.get("/node-refs")
async def admin_list_node_refs(_admin=Depends(require_admin)):
"""Return {node_ref: node_id} for the whole fleet.

The one route that serves the mapping publication exists to withhold (D16),
which is why it is gated on require_admin rather than on a logged-in caller
the way the leaderboard is. The admin pages are built on the public,
ref-keyed feeds, so this is what lets them name a node to an operator, join
the node_id-keyed admin routes beside it, and link to the node's own site —
which is named after the node_id, not the ref.

While a deployment sets AUTH_ALLOW_ANONYMOUS_ADMIN with no OAuth configured,
require_admin admits every caller and this mapping is public there. Closing
that door is ClickUp 86cb1emcx; until it closes, treat any environment with
the bypass on as publishing the whole boundary, not just this route.
"""
with state.connected_nodes_lock:
connected = list(state.connected_nodes)
return ref_to_id_map(connected)


@router.get("/node-contacts")
async def admin_list_node_contacts(
session: AsyncSession = Depends(get_async_session),
Expand Down
21 changes: 21 additions & 0 deletions backend/services/node_refs.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,27 @@ def id_for_identity(identity: str | None) -> str | None:
return id_for_ref(identity)


def ref_to_id_map(node_ids: Iterable[str] = ()) -> dict[str, str]:
"""Every published handle and the node behind it: {node_ref: node_id}.

The inverse of the boundary, so only a caller already entitled to both
identifiers may be handed it — today that is routes/admin.py's `node-refs`,
gated on require_admin. Everything else resolves one node at a time.

`node_ids` widens the registry with ids the caller holds, resolved through
`owner_identity` so a mirrored node and a synthetic one appear under the
same handle they publish as. A registry row wins over a mirrored ref, as it
does on the way out; a node with no handle at all is absent rather than
named under its own id.
"""
_refresh()
mapping = dict(_reverse)
for node_id in node_ids:
if ref := owner_identity(node_id):
mapping.setdefault(ref, node_id)
return mapping


def _names_a_node(value: str, known_ids: Iterable[str]) -> bool:
"""Whether a string is the private id of a node.

Expand Down
53 changes: 53 additions & 0 deletions backend/tests/test_admin_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,3 +658,56 @@ def test_both_routes_are_gated_on_require_admin(self):

assert "/api/admin/node-contacts" in gated
assert "/api/admin/nodes/{node_id}/contact" in gated


class TestNodeRefs:
"""The one route that hands back the mapping publication withholds."""

@staticmethod
def _register(node_id, node_ref):
import asyncio

from core.nodes import Node
from core.users import async_session_maker

async def _go():
async with async_session_maker() as session:
session.add(Node(node_id=node_id, node_ref=node_ref))
await session.commit()

asyncio.run(_go())
asyncio.set_event_loop(asyncio.new_event_loop())

def teardown_method(self):
with state.connected_nodes_lock:
state.connected_nodes.clear()

def test_it_names_the_node_behind_a_ref(self, client):
self._register("ret1a2b3c4d", "nde1a2b3c4d00")

body = client.get("/api/admin/node-refs").json()

assert body["nde1a2b3c4d00"] == "ret1a2b3c4d"

def test_it_covers_a_connected_node_the_registry_cannot_answer_for(self, client):
"""A mirrored node carries its ref in the fleet snapshot, not a row."""
with state.connected_nodes_lock:
state.connected_nodes["ret0badcafe"] = {"status": "active", "node_ref": "ndemirrored001"}

body = client.get("/api/admin/node-refs").json()

assert body["ndemirrored001"] == "ret0badcafe"

def test_it_is_gated_on_require_admin(self):
"""The suite runs with AUTH_ALLOW_ANONYMOUS_ADMIN=1, so the gate is
asserted where it is declared rather than by a refused request."""
from core.users import require_admin

gated = {
route.path
for route in app.routes
if getattr(route, "dependant", None)
and any(dep.call is require_admin for dep in route.dependant.dependencies)
}

assert "/api/admin/node-refs" in gated
47 changes: 47 additions & 0 deletions backend/tests/test_node_refs.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,3 +360,50 @@ def test_a_mirrored_node_reaches_the_published_feed(self, seed):
self._connected(node_ref="ndemirrored001")
out = node_refs.substitute_identities({"aircraft": [{"hex": "abc123", "node_id": _MIRRORED}]})
assert out["aircraft"] == [{"hex": "abc123", "node_ref": "ndemirrored001"}]


class TestRefToIdMap:
"""The inverse of the boundary, for the admin routes allowed to cross it."""

def _connected(self, node_id, **entry):
from core import state

with state.connected_nodes_lock:
state.connected_nodes[node_id] = {"is_synthetic": False, "status": "active", **entry}

def teardown_method(self):
from core import state

with state.connected_nodes_lock:
state.connected_nodes.clear()

def test_it_names_the_node_behind_every_registered_ref(self, seed):
seed(ret1a2b3c4d="nde1a2b3c4d00", ret9f8e7d6c="nde9f8e7d6c00")
assert node_refs.ref_to_id_map() == {
"nde1a2b3c4d00": "ret1a2b3c4d",
"nde9f8e7d6c00": "ret9f8e7d6c",
}

def test_a_mirrored_node_resolves_through_the_ids_it_is_given(self, seed):
"""It has no row here, so only a caller holding the id can place it."""
seed(ret1a2b3c4d="nde1a2b3c4d00")
self._connected(_MIRRORED, node_ref="ndemirrored001")
assert node_refs.ref_to_id_map([_MIRRORED])["ndemirrored001"] == _MIRRORED

def test_the_registry_wins_over_a_mirrored_ref(self, seed):
seed(**{_MIRRORED: "ndelocalrow001"})
self._connected(_MIRRORED, node_ref="ndemirrored001")
mapping = node_refs.ref_to_id_map([_MIRRORED])
assert mapping["ndelocalrow001"] == _MIRRORED
assert "ndemirrored001" not in mapping

def test_a_synthetic_node_maps_to_itself(self, seed):
"""It publishes under its own id, so that id is both halves."""
seed(ret1a2b3c4d="nde1a2b3c4d00")
self._connected("synth-node-1", is_synthetic=True)
assert node_refs.ref_to_id_map(["synth-node-1"])["synth-node-1"] == "synth-node-1"

def test_a_node_with_no_handle_at_all_is_left_out(self, seed):
seed(ret1a2b3c4d="nde1a2b3c4d00")
self._connected(_MIRRORED)
assert node_refs.ref_to_id_map([_MIRRORED]) == {"nde1a2b3c4d00": "ret1a2b3c4d"}
4 changes: 4 additions & 0 deletions dashboard/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ export const api = {
adminRevokeInvite: (token) =>
request(`/api/admin/invites/${encodeURIComponent(token)}`, { method: "DELETE" }),

// Admin: node identity. {node_ref: node_id} for the fleet — the one route
// that crosses the publication boundary, which is why it is admin-only.
adminNodeRefs: () => request("/api/admin/node-refs"),

// Admin: node ownership
adminNodeOwners: () => request("/api/admin/node-owners"),
adminNodeContacts: () => request("/api/admin/node-contacts"),
Expand Down
9 changes: 6 additions & 3 deletions dashboard/src/components/RetnodeLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const RETNODE_DOMAIN = "retnode.com";
// link rather than a URL that cannot resolve.
const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;

export function retnodeUrl(nodeId: string): string | null {
export function retnodeUrl(nodeId: string | null | undefined): string | null {
if (!nodeId || !DNS_LABEL.test(nodeId)) return null;
return `https://${nodeId.toLowerCase()}.${RETNODE_DOMAIN}`;
}
Expand All @@ -21,7 +21,10 @@ const externalLinkIcon = (
);

type Props = {
nodeId: string;
/** The node's private id, which its site is named after — never the node_ref
* standing in for it, which names nothing. Null where the caller reads a
* ref-keyed feed and could not resolve one; the label then stands alone. */
nodeId: string | null;
/** From the node payload's `is_synthetic`. Simulated and test nodes have no
* box behind them, so they get no link; an absent value links, since only a
* positive verdict is evidence there is nothing to open. */
Expand All @@ -34,7 +37,7 @@ type Props = {
* there is nothing to open, so a caller can use it wherever an id appears.
* Swallows the click: the Nodes page wraps it in a card that navigates. */
export function RetnodeLink({ nodeId, synthetic, children }: Props) {
const label = children ?? nodeId;
const label = children ?? nodeId ?? "";
const url = synthetic ? null : retnodeUrl(nodeId);
if (!url) return <>{label}</>;
return (
Expand Down
39 changes: 39 additions & 0 deletions dashboard/src/components/useNodeIds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";

import { api } from "../api/client";

/** `{node_ref: node_id}` for the fleet, or `null` until the answer is in.
*
* The admin pages are built on the public feeds, which are keyed on node_ref
* and carry no node_id at all, so this is what puts the private identifier in
* front of an admin: the node's own site is named after it, and the contact
* and location-privacy routes are keyed on it.
*
* Fetched once per mount and soft-failing to an empty map, so a page that
* lists the fleet is not lost with the ids it would have carried. A node that
* registers while the page is open therefore shows no id until it is
* reloaded; polling for that would cost a request per tick on a page that
* already polls, to catch an event that happens a few times a month.
*
* The null is what keeps "not asked yet" apart from "asked, and this ref has
* no id": the same absence, wanting different words.
*/
export function useNodeIds(): Record<string, string> | null {
const [idsByRef, setIdsByRef] = useState<Record<string, string> | null>(null);

useEffect(() => {
let cancelled = false;
api
.adminNodeRefs()
.then((m) => { if (!cancelled) setIdsByRef(m || {}); })
.catch((e) => {
// Logged before the fallback: an empty map is also what a fleet with no
// registered node looks like, and the two should not read the same.
console.error("node ids unavailable", e);
if (!cancelled) setIdsByRef({});
});
return () => { cancelled = true; };
}, []);

return idsByRef;
}
31 changes: 20 additions & 11 deletions dashboard/src/pages/admin/CustodyPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import { api } from "../../api/client";
import { useNodeIds } from "../../components/useNodeIds";

const PAGE_SIZE = 25;

Expand All @@ -8,6 +9,7 @@ export default function CustodyPage() {
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const [search, setSearch] = useState("");
const idsByRef = useNodeIds();

useEffect(() => {
api.custody()
Expand All @@ -18,10 +20,13 @@ export default function CustodyPage() {

if (loading) return <div className="empty-state">Loading…</div>;

const nodeIds = Object.keys(custody?.node_keys || {});
// The custody payload is published, so it is keyed on node_ref throughout.
const refs = Object.keys(custody?.node_keys || {});
const filtered = search
? nodeIds.filter((id) => id.toLowerCase().includes(search.toLowerCase()))
: nodeIds;
? refs.filter((ref) =>
[ref, idsByRef?.[ref]].some((s) => (s || "").toLowerCase().includes(search.toLowerCase())),
)
: refs;
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const paged = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);

Expand All @@ -35,12 +40,12 @@ export default function CustodyPage() {
<div className="stats-grid">
<div className="stat-card accent">
<div className="stat-label">Registered Nodes</div>
<div className="stat-value">{custody?.registered_nodes ?? nodeIds.length}</div>
<div className="stat-value">{custody?.registered_nodes ?? refs.length}</div>
</div>
<div className="stat-card success">
<div className="stat-label">With Chain Entries</div>
<div className="stat-value">
{nodeIds.filter((id) => (custody?.chain_entries?.[id]?.count || 0) > 0).length}
{refs.filter((ref) => (custody?.chain_entries?.[ref]?.count || 0) > 0).length}
</div>
</div>
</div>
Expand All @@ -62,6 +67,7 @@ export default function CustodyPage() {
<table>
<thead>
<tr>
<th>Node ref</th>
<th>Node ID</th>
<th>Status</th>
<th>Chain Length</th>
Expand All @@ -72,22 +78,25 @@ export default function CustodyPage() {
</tr>
</thead>
<tbody>
{paged.map((nodeId) => {
const chain = custody?.chain_entries?.[nodeId] || {};
{paged.map((ref) => {
const chain = custody?.chain_entries?.[ref] || {};
const count = chain.count || 0;
const verified = chain.latest_verified === true;
const keyInfo = custody?.node_keys?.[nodeId] || {};
const keyInfo = custody?.node_keys?.[ref] || {};
return (
<tr key={nodeId}>
<td style={{ fontFamily: "monospace", fontSize: 12 }}>{nodeId}</td>
<tr key={ref}>
<td style={{ fontFamily: "monospace", fontSize: 12 }}>{ref}</td>
<td style={{ fontFamily: "monospace", fontSize: 12, color: "var(--text-muted)" }}>
{idsByRef?.[ref] ?? "—"}
</td>
<td>
<span className={`badge ${verified ? "online" : count > 0 ? "warning" : "offline"}`}>
{verified ? "Verified" : count > 0 ? "Unverified" : "None"}
</span>
</td>
<td>{count}</td>
<td style={{ fontFamily: "monospace", fontSize: 11 }}>{chain.latest_hour || "—"}</td>
<td>{custody?.iq_commitments?.[nodeId] || 0}</td>
<td>{custody?.iq_commitments?.[ref] || 0}</td>
<td>{keyInfo.signing_mode || "—"}</td>
<td style={{ fontFamily: "monospace", fontSize: 11 }}>{keyInfo.fingerprint || "—"}</td>
</tr>
Expand Down
Loading
Loading