From 812fe8bc04d0d05d06ba61a06974cf72080fd1dd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Sep 2026 20:06:41 +0100 Subject: [PATCH 01/47] feat(web-console): name peers by petname, self display name or short id A petname is this viewer's own label for a device, kept in the console's own storage and never gossiped. A self display name is read from the agent/self entry of a verified advert, the key agent-comms and the public hub's advert policy already carry, and this console publishes its own through the same entry. The convention is implemented once in peer-names.ts and reached through a context by the conversation list, the connection directory and the discovered-peers table. --- ts/packages/web-console/src/App.tsx | 180 ++++++++++-------- .../src/components/ConnectionPanel.tsx | 23 ++- .../src/components/ConversationList.tsx | 7 +- .../src/components/DiscoveredPeersPanel.tsx | 5 +- .../web-console/src/components/PeerLabel.tsx | 114 +++++++++++ .../web-console/src/components/PeerName.tsx | 13 ++ .../src/components/PeerNamesProvider.tsx | 85 +++++++++ .../web-console/src/components/RoomPanel.tsx | 4 +- .../src/components/SelfNameField.tsx | 43 +++++ ts/packages/web-console/src/conversations.ts | 9 +- .../web-console/src/hooks/use-peer-names.ts | 27 +++ ts/packages/web-console/src/main.tsx | 3 + ts/packages/web-console/src/name-store.ts | 54 ++++++ ts/packages/web-console/src/peer-names.ts | 71 +++++++ .../web-console/test/App.unit.test.tsx | 123 +++++++++++- .../test/ConversationList.unit.test.tsx | 32 +++- .../web-console/test/PeerLabel.unit.test.tsx | 99 ++++++++++ .../web-console/test/RoomPanel.unit.test.tsx | 6 +- .../test/conversations.unit.test.ts | 10 +- .../web-console/test/name-store.unit.test.ts | 48 +++++ .../web-console/test/names-harness.tsx | 25 +++ .../web-console/test/peer-names.unit.test.ts | 111 +++++++++++ 22 files changed, 985 insertions(+), 107 deletions(-) create mode 100644 ts/packages/web-console/src/components/PeerLabel.tsx create mode 100644 ts/packages/web-console/src/components/PeerName.tsx create mode 100644 ts/packages/web-console/src/components/PeerNamesProvider.tsx create mode 100644 ts/packages/web-console/src/components/SelfNameField.tsx create mode 100644 ts/packages/web-console/src/hooks/use-peer-names.ts create mode 100644 ts/packages/web-console/src/name-store.ts create mode 100644 ts/packages/web-console/src/peer-names.ts create mode 100644 ts/packages/web-console/test/PeerLabel.unit.test.tsx create mode 100644 ts/packages/web-console/test/name-store.unit.test.ts create mode 100644 ts/packages/web-console/test/names-harness.tsx create mode 100644 ts/packages/web-console/test/peer-names.unit.test.ts diff --git a/ts/packages/web-console/src/App.tsx b/ts/packages/web-console/src/App.tsx index 17156af3..5ade11f7 100644 --- a/ts/packages/web-console/src/App.tsx +++ b/ts/packages/web-console/src/App.tsx @@ -35,6 +35,10 @@ import type { WebrtcNegotiator } from "./webrtc-negotiation.js"; import { ConnectionPanel, deviceHex } from "./components/ConnectionPanel.js"; import { DiscoveredPeersPanel } from "./components/DiscoveredPeersPanel.js"; import type { DiscoveredPeerRow } from "./components/DiscoveredPeersPanel.js"; +import { PeerNamesProvider } from "./components/PeerNamesProvider.js"; +import { PeerName } from "./components/PeerName.js"; +import { SelfNameField } from "./components/SelfNameField.js"; +import type { NameStore } from "./name-store.js"; import { ConversationList } from "./components/ConversationList.js"; import { RoomPanel } from "./components/RoomPanel.js"; import { useRoomMessaging } from "./hooks/use-room-messaging.js"; @@ -54,6 +58,8 @@ export interface AppProps { roomStorage: KeyValueStorage; /** What each node last announced about the certificates it serves, so a node's address keeps working as its certificates change. */ certificateMemory: CertificateMemory; + /** The petnames this viewer has given peers and this console's own display name, kept in the console's own storage. */ + nameStore: NameStore; /** Attempts same-device node auto-discovery once, on mount. Defaults to the real `discoverLocalNode` (a no-op when this console is served from a loopback origin, a real localhost probe otherwise); tests inject a fake to avoid depending on `location`/`fetch`. */ discoverLocalNode?: () => Promise; // Seeds the Node field. Left as a prop (rather than App reading location/import.meta.env itself) so App stays the plain, testable component its own header comment describes; main.tsx computes the real value via default-hub-address.ts. @@ -105,6 +111,7 @@ export function App({ messageStore, roomStorage, certificateMemory, + nameStore, discoverLocalNode = discoverLocalNodeDefault, defaultAddress = DEFAULT_ADDRESS, }: Readonly): React.JSX.Element { @@ -319,93 +326,100 @@ export function App({ } return ( - - wire-mesh console -
- - { - setAddress(event.currentTarget.value); - }} - required - style={{ flex: 1, minWidth: "16rem" }} - /> - - - - - {AVAILABLE_DOMAINS.map((domain) => ( - - ))} + + + wire-mesh console + + + + { + setAddress(event.currentTarget.value); + }} + required + style={{ flex: 1, minWidth: "16rem" }} + /> + - -
- { - resolveDiscovered(key, true); - }} - onDismiss={(key) => { - resolveDiscovered(key, false); - }} - /> - {connections.map((entry) => ( - { - handleClose(entry); + + + {AVAILABLE_DOMAINS.map((domain) => ( + + ))} + + + + { + resolveDiscovered(key, true); }} - onMessagePeer={(device) => { - handleMessagePeer(entry, device); + onDismiss={(key) => { + resolveDiscovered(key, false); }} /> - ))} - {failures.map((failure) => ( - { - setFailures((current) => - current.filter((entry) => entry.key !== failure.key), - ); - }} - > - {failure.reason}. Messages go through the hub instead. - - ))} - - {selectedConversation !== undefined && ( - - roomMessaging.send(selectedConversation.roomPath, text) - } - onPostNotice={async (text) => - roomMessaging.postNotice(selectedConversation.roomPath, text) - } - onRetry={(localId) => { - void roomMessaging.retry(selectedConversation.roomPath, localId); - }} - onDiscard={(localId) => { - roomMessaging.discard(selectedConversation.roomPath, localId); - }} + {connections.map((entry) => ( + { + handleClose(entry); + }} + onMessagePeer={(device) => { + handleMessagePeer(entry, device); + }} + /> + ))} + {failures.map((failure) => ( + + No direct connection to + + } + withCloseButton + onClose={() => { + setFailures((current) => + current.filter((entry) => entry.key !== failure.key), + ); + }} + > + {failure.reason}. Messages go through the hub instead. + + ))} + - )} -
+ {selectedConversation !== undefined && ( + + roomMessaging.send(selectedConversation.roomPath, text) + } + onPostNotice={async (text) => + roomMessaging.postNotice(selectedConversation.roomPath, text) + } + onRetry={(localId) => { + void roomMessaging.retry(selectedConversation.roomPath, localId); + }} + onDiscard={(localId) => { + roomMessaging.discard(selectedConversation.roomPath, localId); + }} + /> + )} + + ); } diff --git a/ts/packages/web-console/src/components/ConnectionPanel.tsx b/ts/packages/web-console/src/components/ConnectionPanel.tsx index f7eb83ad..8c0e6d4f 100644 --- a/ts/packages/web-console/src/components/ConnectionPanel.tsx +++ b/ts/packages/web-console/src/components/ConnectionPanel.tsx @@ -7,12 +7,16 @@ import type { SessionEvent, } from "wire-mesh-core/domain/mesh-session"; import type { DeviceId } from "wire-mesh-core/generated/protocol"; +import { useEffect } from "react"; import { useMeshSessionEvents } from "../hooks/use-mesh-session-events.js"; import { browserPermissions, explainLocalNetworkBlock, type PermissionQuerier, } from "../local-network.js"; +import { usePeerNames } from "../hooks/use-peer-names.js"; +import { selfNameExtension } from "../peer-names.js"; +import { PeerLabel } from "./PeerLabel.js"; const HEX_RADIX = 16; @@ -108,6 +112,21 @@ export function ConnectionPanel({ }, [failing, address, pageHost, permissions]); const blocked = failing && explained?.address === address ? explained.message : undefined; + const { observeDirectory, selfName } = usePeerNames(); + const connected = event?.state.status === "connected"; + useEffect(() => { + if (event !== undefined) { + observeDirectory(event.directory); + } + }, [event, observeDirectory]); + // A session's initial self-advert carries no extensions, so this console's own display name is published by re-sending it once the link is up, and again whenever the name changes. + useEffect(() => { + if (connected && selfName !== undefined) { + session.sendGossipUpdate(selfNameExtension(selfName)).catch(() => { + // The link dropped between the status read and the send; this effect runs again when the session reconnects, which republishes the name. + }); + } + }, [connected, selfName, session]); const status = event === undefined ? "idle" : describeStatus(event); const directory = event?.directory ?? []; const frameLog = event?.frameLog ?? []; @@ -160,7 +179,9 @@ export function ConnectionPanel({ {directory.map((entry) => ( - {deviceHex(entry.device)} + + + {entry.advert.addresses.join(", ")} {entry.advert["snapshot-seconds"]} diff --git a/ts/packages/web-console/src/components/ConversationList.tsx b/ts/packages/web-console/src/components/ConversationList.tsx index e6ad658f..697fe886 100644 --- a/ts/packages/web-console/src/components/ConversationList.tsx +++ b/ts/packages/web-console/src/components/ConversationList.tsx @@ -2,6 +2,7 @@ import { Badge, Group, NavLink, Stack, Text } from "@mantine/core"; import { participantLabel, type ConversationView } from "../conversations.js"; +import { usePeerNames } from "../hooks/use-peer-names.js"; export interface ConversationListProps { conversations: readonly ConversationView[]; @@ -14,6 +15,7 @@ export function ConversationList({ selected, onSelect, }: Readonly): React.JSX.Element | null { + const names = usePeerNames(); if (conversations.length === 0) { return null; } @@ -26,7 +28,10 @@ export function ConversationList({ names.labelOf(hex).primary, + )} description={ conversation.status === "connected" ? undefined : "offline" } diff --git a/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx b/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx index 29935bc2..9be97f24 100644 --- a/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx +++ b/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx @@ -3,6 +3,7 @@ import { Button, Card, Group, Table, Text } from "@mantine/core"; import type { DeviceId } from "wire-mesh-core/generated/protocol"; import { deviceHex } from "./ConnectionPanel.js"; +import { PeerLabel } from "./PeerLabel.js"; export interface DiscoveredPeerRow { key: string; @@ -40,7 +41,9 @@ export function DiscoveredPeersPanel({ {peers.map((peer) => ( - {deviceHex(peer.device)} + + + {peer.addresses.join(", ")} diff --git a/ts/packages/web-console/src/components/PeerLabel.tsx b/ts/packages/web-console/src/components/PeerLabel.tsx new file mode 100644 index 00000000..b8467234 --- /dev/null +++ b/ts/packages/web-console/src/components/PeerLabel.tsx @@ -0,0 +1,114 @@ +// One peer as a person reads it: the label from the naming convention, the short id as a copy target for the full device-id, and an inline rename that sets this viewer's own petname. + +import { useState } from "react"; +import { + ActionIcon, + CopyButton, + Group, + Text, + TextInput, + Tooltip, +} from "@mantine/core"; +import { usePeerNames } from "../hooks/use-peer-names.js"; + +/** Focuses the rename field as it appears, so the keyboard lands where the click that opened it was aimed. */ +function focusOnMount(input: HTMLInputElement | null): void { + input?.focus(); +} + +export interface PeerLabelProps { + deviceHex: string; +} + +export function PeerLabel({ + deviceHex, +}: Readonly): React.JSX.Element { + const names = usePeerNames(); + const label = names.labelOf(deviceHex); + const [draft, setDraft] = useState(undefined); + + if (draft !== undefined) { + return ( +
{ + event.preventDefault(); + void names.rename(deviceHex, draft).then(() => { + setDraft(undefined); + }); + }} + > + + { + setDraft(event.currentTarget.value); + }} + /> + + ✓ + + { + setDraft(undefined); + }} + > + × + + +
+ ); + } + + return ( + +
+ + {label.primary} + + {label.secondary !== undefined && ( + + {label.secondary} + + )} +
+ + {({ copied, copy }) => ( + + + ⧉ + + + )} + + { + setDraft(names.petnameOf(deviceHex) ?? ""); + }} + > + ✎ + +
+ ); +} diff --git a/ts/packages/web-console/src/components/PeerName.tsx b/ts/packages/web-console/src/components/PeerName.tsx new file mode 100644 index 00000000..218f0f40 --- /dev/null +++ b/ts/packages/web-console/src/components/PeerName.tsx @@ -0,0 +1,13 @@ +// A peer's name as plain inline text, for sentences and messages where PeerLabel's copy and rename controls would be out of place. + +import { usePeerNames } from "../hooks/use-peer-names.js"; + +export interface PeerNameProps { + deviceHex: string; +} + +export function PeerName({ + deviceHex, +}: Readonly): React.JSX.Element { + return <>{usePeerNames().labelOf(deviceHex).primary}; +} diff --git a/ts/packages/web-console/src/components/PeerNamesProvider.tsx b/ts/packages/web-console/src/components/PeerNamesProvider.tsx new file mode 100644 index 00000000..ace1a429 --- /dev/null +++ b/ts/packages/web-console/src/components/PeerNamesProvider.tsx @@ -0,0 +1,85 @@ +// Holds the petnames loaded from the console's name store and the self display names observed in directories, and serves them through PeerNamesContext. + +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { DirectoryEntry } from "wire-mesh-core/domain/mesh-session"; +import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; +import type { NameStore } from "../name-store.js"; +import { labelPeer, selfAssertedName } from "../peer-names.js"; +import { PeerNamesContext } from "../hooks/use-peer-names.js"; +import type { PeerNames } from "../hooks/use-peer-names.js"; + +export interface PeerNamesProviderProps { + store: NameStore; + children: React.ReactNode; +} + +export function PeerNamesProvider({ + store, + children, +}: Readonly): React.JSX.Element { + const [petnames, setPetnames] = useState>( + new Map(), + ); + const [selfNames, setSelfNames] = useState>( + new Map(), + ); + const [selfName, setOwnName] = useState(undefined); + + useEffect(() => { + void store.petnames().then(setPetnames); + void store.selfName().then(setOwnName); + }, [store]); + + const rename = useCallback( + async (deviceHex: string, name: string): Promise => { + await store.setPetname(deviceHex, name); + setPetnames(await store.petnames()); + }, + [store], + ); + + const setSelfName = useCallback( + async (name: string): Promise => { + await store.setSelfName(name); + setOwnName(await store.selfName()); + }, + [store], + ); + + const observeDirectory = useCallback( + (directory: readonly DirectoryEntry[]): void => { + setSelfNames((current) => { + let next: Map | undefined; + for (const entry of directory) { + const name = selfAssertedName(entry.advert); + const hex = deviceIdToHex(entry.device); + if (name !== undefined && current.get(hex) !== name) { + next ??= new Map(current); + next.set(hex, name); + } + } + return next ?? current; + }); + }, + [], + ); + + const value = useMemo( + () => ({ + labelOf: (deviceHex) => + labelPeer(deviceHex, petnames.get(deviceHex), selfNames.get(deviceHex)), + petnameOf: (deviceHex) => petnames.get(deviceHex), + rename, + selfName, + setSelfName, + observeDirectory, + }), + [petnames, selfNames, selfName, rename, setSelfName, observeDirectory], + ); + + return ( + + {children} + + ); +} diff --git a/ts/packages/web-console/src/components/RoomPanel.tsx b/ts/packages/web-console/src/components/RoomPanel.tsx index a3cbea22..4d8266a3 100644 --- a/ts/packages/web-console/src/components/RoomPanel.tsx +++ b/ts/packages/web-console/src/components/RoomPanel.tsx @@ -3,6 +3,7 @@ import { Alert, Button, Divider, Group, Stack, Text } from "@mantine/core"; import { SubmitRow } from "web-ui-primitives"; import { participantLabel, type ConversationView } from "../conversations.js"; +import { usePeerNames } from "../hooks/use-peer-names.js"; import { MessageList } from "./MessageList.js"; import { NoticesView } from "./NoticesView.js"; @@ -36,6 +37,7 @@ export function RoomPanel({ onRetry, onDiscard, }: Readonly): React.JSX.Element { + const names = usePeerNames(); return ( @@ -82,7 +84,7 @@ export function RoomPanel({ names.labelOf(hex).primary)} onRetry={onRetry} onDiscard={onDiscard} /> diff --git a/ts/packages/web-console/src/components/SelfNameField.tsx b/ts/packages/web-console/src/components/SelfNameField.tsx new file mode 100644 index 00000000..3e5d9ae5 --- /dev/null +++ b/ts/packages/web-console/src/components/SelfNameField.tsx @@ -0,0 +1,43 @@ +// The field for this console's own display name: what it publishes, signed by its device key, to every node it connects to. Empty means it publishes none and peers see only its short id. + +import { useState } from "react"; +import { Button, Group, TextInput } from "@mantine/core"; +import { usePeerNames } from "../hooks/use-peer-names.js"; +import { MAX_NAME_LENGTH } from "../peer-names.js"; + +export function SelfNameField(): React.JSX.Element { + const { selfName, setSelfName } = usePeerNames(); + // The text being typed, or undefined while the field just shows the saved name. + const [edit, setEdit] = useState(undefined); + const draft = edit ?? selfName ?? ""; + return ( +
{ + event.preventDefault(); + void setSelfName(draft).then(() => { + setEdit(undefined); + }); + }} + > + + { + setEdit(event.currentTarget.value); + }} + style={{ flex: 1, minWidth: "16rem" }} + /> + + +
+ ); +} diff --git a/ts/packages/web-console/src/conversations.ts b/ts/packages/web-console/src/conversations.ts index 1aa7b469..15bf1a6e 100644 --- a/ts/packages/web-console/src/conversations.ts +++ b/ts/packages/web-console/src/conversations.ts @@ -277,14 +277,11 @@ export function participantsOf( return parsed.owner === ownDeviceHex ? [] : [parsed.owner]; } -/** How a conversation's other members are named in the UI. A device-id is long, so this shows enough leading hex to tell devices apart at a glance; a conversation with no other members is named by its room path. */ -const DEVICE_LABEL_LENGTH = 12; - +/** How a conversation's other members are named in the UI: each by `nameOf`, which applies the console's naming convention to a device-id. A conversation with no other members is named by its room path. */ export function participantLabel( view: Readonly>, + nameOf: (deviceHex: string) => string, ): string { if (view.participants.length === 0) return view.roomPath; - return view.participants - .map((hex) => hex.slice(0, DEVICE_LABEL_LENGTH)) - .join(", "); + return view.participants.map(nameOf).join(", "); } diff --git a/ts/packages/web-console/src/hooks/use-peer-names.ts b/ts/packages/web-console/src/hooks/use-peer-names.ts new file mode 100644 index 00000000..67c518af --- /dev/null +++ b/ts/packages/web-console/src/hooks/use-peer-names.ts @@ -0,0 +1,27 @@ +// The naming context every peer-bearing component reads, so the display convention (petname, then self display name, then short id) is implemented once in peer-names.ts and reached from ConversationList, the connection directory and the discovered-peers table alike. + +import { createContext, useContext } from "react"; +import type { DirectoryEntry } from "wire-mesh-core/domain/mesh-session"; +import type { PeerLabel } from "../peer-names.js"; + +export interface PeerNames { + labelOf: (deviceHex: string) => PeerLabel; + petnameOf: (deviceHex: string) => string | undefined; + /** Gives a device a petname of this viewer's own, or removes it when blank. */ + rename: (deviceHex: string, name: string) => Promise; + /** This console's own display name, published in its gossip adverts. */ + selfName: string | undefined; + setSelfName: (name: string) => Promise; + /** Records the display names the verified adverts of a directory assert. */ + observeDirectory: (directory: readonly DirectoryEntry[]) => void; +} + +export const PeerNamesContext = createContext(undefined); + +export function usePeerNames(): PeerNames { + const names = useContext(PeerNamesContext); + if (names === undefined) { + throw new Error("usePeerNames must be used inside PeerNamesProvider"); + } + return names; +} diff --git a/ts/packages/web-console/src/main.tsx b/ts/packages/web-console/src/main.tsx index 106b0729..88626e86 100644 --- a/ts/packages/web-console/src/main.tsx +++ b/ts/packages/web-console/src/main.tsx @@ -7,6 +7,7 @@ import { MantineProvider } from "@mantine/core"; import { createIndexedDbStorage } from "./adapters/indexeddb-storage.js"; import { createPersistedWebCryptoIdentity } from "./adapters/web-crypto-identity.js"; import { createCertificateMemory } from "./certificate-memory.js"; +import { createNameStore } from "./name-store.js"; import { createMessageStore } from "./message-store.js"; import { App } from "./App.js"; import { PwaUpdatePrompt } from "./components/PwaUpdatePrompt.js"; @@ -21,6 +22,7 @@ const roomStorage = await createIndexedDbStorage(); const certificateMemory = createCertificateMemory( await createIndexedDbStorage(), ); +const nameStore = createNameStore(await createIndexedDbStorage()); const address = defaultHubAddress(import.meta.env.DEV, window.location); const container = document.getElementById("root"); @@ -37,6 +39,7 @@ createRoot(container).render( messageStore={messageStore} roomStorage={roomStorage} certificateMemory={certificateMemory} + nameStore={nameStore} defaultAddress={address} /> diff --git a/ts/packages/web-console/src/name-store.ts b/ts/packages/web-console/src/name-store.ts new file mode 100644 index 00000000..d4fd70e2 --- /dev/null +++ b/ts/packages/web-console/src/name-store.ts @@ -0,0 +1,54 @@ +// Persistence for the names this console keeps about itself and its peers, over the portable KeyValueStorage port. Petnames (device-id to this viewer's own label) and this console's own display name are local to the storage they are written to and are never sent anywhere by this module. + +import type { KeyValueStorage } from "wire-mesh-core/ports/storage"; +import { cleanName } from "./peer-names.js"; + +const PETNAME_PREFIX = "petname/"; +const SELF_NAME_KEY = "self-name"; + +export interface NameStore { + /** Every petname held, keyed by device-id hex. */ + petnames: () => Promise>; + /** Sets the petname for a device, or removes it when `name` is blank. */ + setPetname: (deviceHex: string, name: string) => Promise; + /** This console's own display name, or undefined when none is set. */ + selfName: () => Promise; + /** Sets this console's own display name, or clears it when `name` is blank. */ + setSelfName: (name: string) => Promise; +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export function createNameStore(storage: Readonly): NameStore { + async function write(key: string, name: string): Promise { + const cleaned = cleanName(name); + if (cleaned === undefined) { + await storage.delete(key); + return; + } + await storage.set(key, new Uint8Array(encoder.encode(cleaned))); + } + return { + async petnames() { + const names = new Map(); + for (const key of await storage.keys(PETNAME_PREFIX)) { + const value = await storage.get(key); + if (value !== undefined) { + names.set(key.slice(PETNAME_PREFIX.length), decoder.decode(value)); + } + } + return names; + }, + async setPetname(deviceHex, name) { + await write(PETNAME_PREFIX + deviceHex, name); + }, + async selfName() { + const value = await storage.get(SELF_NAME_KEY); + return value === undefined ? undefined : decoder.decode(value); + }, + async setSelfName(name) { + await write(SELF_NAME_KEY, name); + }, + }; +} diff --git a/ts/packages/web-console/src/peer-names.ts b/ts/packages/web-console/src/peer-names.ts new file mode 100644 index 00000000..c2f3e5fc --- /dev/null +++ b/ts/packages/web-console/src/peer-names.ts @@ -0,0 +1,71 @@ +// How a peer is named wherever it appears in the console. A device-id is 64 hex characters, which no person can hold in mind, so every surface shows the same three-tier label: a petname this viewer gave the device, else the name the device signed about itself, else a short id. Petnames live in this console's own storage and are never gossiped, so there is no global authority and no collision to resolve. The convention matches agent-comms, so the two surfaces read the same. + +import type { PeerAdvert } from "wire-mesh-core/generated/protocol"; + +/** Leading hex characters of a device-id shown as its short id: enough to tell devices apart at a glance, and short enough to read aloud. */ +export const SHORT_ID_LENGTH = 12; + +/** A name longer than this is cut when shown. A self-asserted name arrives from a stranger through a hub that only checks it is a string, so the console bounds what it will lay out. */ +export const MAX_NAME_LENGTH = 64; + +/** The gossip extension a self display name travels in. It is the key agent-comms already publishes its agent name under, and the one the public hub's advert policy carries, so a console name reaches peers through that hub and agent-comms names appear here without a second convention. The advert signature covers every extension entry, so the name is signed by the device's own key. */ +export const SELF_ADVERT_KEY = "agent/self"; + +/** The `harness` the same extension requires alongside `name`. */ +export const CONSOLE_HARNESS = "wire-mesh-console"; + +/** Which tier of the naming convention produced a label. */ +export type PeerLabelSource = "petname" | "self" | "id"; + +export interface PeerLabel { + /** The text that names the peer: the petname, else the self display name, else the short id. */ + primary: string; + /** What to show beside the primary text, or undefined when the primary is already the short id. A petname is shown with the peer's own claim so a renamed peer can still be recognised; a self name is shown with the short id so two peers claiming one name can be told apart. */ + secondary: string | undefined; + source: PeerLabelSource; +} + +export function shortId(deviceHex: string): string { + return deviceHex.slice(0, SHORT_ID_LENGTH); +} + +/** A name fit to display: trimmed, non-empty, and within MAX_NAME_LENGTH. */ +export function cleanName(name: string): string | undefined { + const trimmed = name.trim(); + if (trimmed === "") return undefined; + return trimmed.slice(0, MAX_NAME_LENGTH); +} + +/** The display name a peer signed about itself, read from its verified advert, or undefined when it published none or a malformed one. */ +export function selfAssertedName( + advert: Readonly, +): string | undefined { + const claim: unknown = advert[SELF_ADVERT_KEY]; + if (typeof claim !== "object" || claim === null) return undefined; + if (!("name" in claim) || typeof claim.name !== "string") return undefined; + return cleanName(claim.name); +} + +/** The extension bag that publishes `name` as this device's own display name. */ +export function selfNameExtension(name: string): Record { + return { [SELF_ADVERT_KEY]: { name, harness: CONSOLE_HARNESS } }; +} + +export function labelPeer( + deviceHex: string, + petname: string | undefined, + selfName: string | undefined, +): PeerLabel { + if (petname !== undefined) { + return { + primary: petname, + secondary: + selfName !== undefined && selfName !== petname ? selfName : undefined, + source: "petname", + }; + } + if (selfName !== undefined) { + return { primary: selfName, secondary: shortId(deviceHex), source: "self" }; + } + return { primary: shortId(deviceHex), secondary: undefined, source: "id" }; +} diff --git a/ts/packages/web-console/test/App.unit.test.tsx b/ts/packages/web-console/test/App.unit.test.tsx index b7e3576f..2b9d7cc3 100644 --- a/ts/packages/web-console/test/App.unit.test.tsx +++ b/ts/packages/web-console/test/App.unit.test.tsx @@ -19,14 +19,24 @@ import { } from "@testing-library/react"; import { MantineProvider } from "@mantine/core"; import type { IdentityPort } from "wire-mesh-core/ports/identity"; +import type { KeyValueStorage } from "wire-mesh-core/ports/storage"; import type { Clock } from "wire-mesh-core/ports/clock"; import type { GossipFrame } from "wire-mesh-core/generated/protocol"; -import { messageFromFrame } from "wire-mesh-core/adapters/frame-codec"; +import { + decodeMessage, + messageFromFrame, +} from "wire-mesh-core/adapters/frame-codec"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import { dmRoomPath } from "wire-mesh-core/domain/room-path"; import { signPeerAdvert } from "wire-mesh-core/domain/peer-advert"; import { createWebCryptoIdentity } from "../src/adapters/web-crypto-identity.js"; import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage"; +import { createNameStore } from "../src/name-store.js"; +import { + selfAssertedName, + selfNameExtension, + shortId, +} from "../src/peer-names.js"; import { createCertificateMemory } from "../src/certificate-memory.js"; import { App } from "../src/App.js"; import type { MessageStore, StoredMessage } from "../src/message-store.js"; @@ -70,12 +80,14 @@ function renderApp( discoverLocalNode?: () => Promise; defaultAddress?: string; messageStore?: MessageStore; + nameStorage?: KeyValueStorage; }> = {}, ): ReturnType { const { discoverLocalNode, defaultAddress, messageStore = fakeMessageStore(), + nameStorage = createMemoryStorage(), } = options; return render( @@ -85,6 +97,7 @@ function renderApp( messageStore={messageStore} roomStorage={createMemoryStorage()} certificateMemory={createCertificateMemory(createMemoryStorage())} + nameStore={createNameStore(nameStorage)} {...(discoverLocalNode === undefined ? {} : { discoverLocalNode })} {...(defaultAddress === undefined ? {} : { defaultAddress })} /> @@ -103,6 +116,11 @@ const GOSSIPED_ADDRESS = "203.0.113.5:4433"; let gossipedFrame: GossipFrame; let gossipedDeviceHex: string; +/** A second remote whose advert carries a self display name, and the hex of the device it names. */ +const SELF_ASSERTED_NAME = "Ada's laptop"; +let namedFrame: GossipFrame; +let namedDeviceHex: string; + beforeAll(async () => { appIdentity = await createWebCryptoIdentity(); const remote = await createWebCryptoIdentity(); @@ -114,6 +132,20 @@ beforeAll(async () => { }); gossipedFrame = { type: "gossip", peers: [advert] }; gossipedDeviceHex = deviceIdToHex(remote.deviceId); + const named = await createWebCryptoIdentity(); + namedFrame = { + type: "gossip", + peers: [ + await signPeerAdvert(named, { + ...selfNameExtension(SELF_ASSERTED_NAME), + device: named.deviceId, + addresses: [GOSSIPED_ADDRESS], + "snapshot-seconds": 0, + "identity-key": named.identityKey, + }), + ], + }; + namedDeviceHex = deviceIdToHex(named.deviceId); }); /** messageFromFrame's own Uint8Array may be a view into a larger backing buffer -- slicing to its own byteOffset/byteLength before handing it to emitMessage is what every other adapter test here already does (see websocket-transport.integration.test.ts's identical helper), since FakeWebSocket.emitMessage takes the raw buffer, not a view onto it. */ @@ -127,7 +159,7 @@ function arrayBuffer(bytes: Uint8Array): ArrayBuffer { /** The discovered-peer table row for a given device within an already-confirmed-present discovered-peers panel -- scoped lookup so its own "Connect" button can be found distinctly from both the connect form's identically-labelled submit button and the identical device hex rendered elsewhere (a device's own hex also renders inside its originating ConnectionPanel's unrelated "Peer directory" table). Callers must await screen.findByTestId("discovered-peers") first: the panel itself never mounts at all while no peer is pending, so this cannot also do that initial wait. */ function discoveredRow(deviceHex: string): HTMLElement { const row = within(screen.getByTestId("discovered-peers")) - .getByText(deviceHex) + .getByText(shortId(deviceHex)) .closest("tr"); if (row === null) { throw new Error(`expected a table row for device ${deviceHex}`); @@ -135,6 +167,36 @@ function discoveredRow(deviceHex: string): HTMLElement { return row; } +/** Connects the form's address and opens its socket, resolving once the session reports connected, which is when its frame listener is registered. */ +async function connectRoot(): Promise { + submitConnectForm(); + await vi.waitFor(() => { + expect(sockets).toHaveLength(1); + }); + const rootSocket = sockets[0]; + if (rootSocket === undefined) { + throw new Error("expected the root socket to exist"); + } + rootSocket.emitOpen(); + await screen.findByText(/^connected/); + return rootSocket; +} + +/** The display names carried by the gossip frames a socket sent after `from` sends. */ +function publishedSelfNames( + socket: Readonly, + from: number, +): unknown[] { + return socket.sent.slice(from).flatMap((data) => { + const frame = decodeMessage(data); + if (frame.type !== "gossip") return []; + return frame.peers.flatMap((advert) => { + const name = selfAssertedName(advert); + return name === undefined ? [] : [name]; + }); + }); +} + describe("App", () => { beforeEach(() => { sockets = []; @@ -275,7 +337,9 @@ describe("App", () => { rootSocket.emitMessage(arrayBuffer(messageFromFrame(gossipedFrame))); const panel = await screen.findByTestId("discovered-peers"); - expect(within(panel).getByText(gossipedDeviceHex)).toBeInTheDocument(); + expect( + within(panel).getByText(shortId(gossipedDeviceHex)), + ).toBeInTheDocument(); expect(within(panel).getByText(GOSSIPED_ADDRESS)).toBeInTheDocument(); fireEvent.click( @@ -308,9 +372,15 @@ describe("App", () => { rootSocket.emitMessage(arrayBuffer(messageFromFrame(gossipedFrame))); // jsdom has no RTCPeerConnection, so the negotiation fails as soon as it starts. - const directoryRow = (await screen.findAllByText(gossipedDeviceHex)) + const directoryRow = ( + await screen.findAllByText(shortId(gossipedDeviceHex)) + ) .map((cell) => cell.closest("tr")) - .find((row) => row?.querySelector("button")?.textContent === "Message"); + .find( + (row) => + row !== null && + within(row).queryByRole("button", { name: "Message" }) !== null, + ); if (directoryRow === null || directoryRow === undefined) { throw new Error("expected a directory row with a Message button"); } @@ -321,7 +391,7 @@ describe("App", () => { // The conversation is open over the hub at once, and only the direct connection failed. const alert = await screen.findByRole("alert"); expect(alert).toHaveTextContent( - `No direct connection to ${gossipedDeviceHex}`, + `No direct connection to ${shortId(gossipedDeviceHex)}`, ); expect(alert).toHaveTextContent(/RTCPeerConnection/); expect(alert).toHaveTextContent("Messages go through the hub instead"); @@ -357,4 +427,45 @@ describe("App", () => { expect(screen.queryByTestId("discovered-peers")).toBeNull(); expect(sockets).toHaveLength(1); }); + + it("names a gossiped peer by the display name its signed advert asserts, with its short id beside it", async () => { + renderApp(); + + const rootSocket = await connectRoot(); + rootSocket.emitMessage(arrayBuffer(messageFromFrame(namedFrame))); + + const panel = await screen.findByTestId("discovered-peers"); + expect(within(panel).getByText(SELF_ASSERTED_NAME)).toBeInTheDocument(); + expect( + within(panel).getByText(shortId(namedDeviceHex)), + ).toBeInTheDocument(); + }); + + it("shows a petname from the console's own storage ahead of the name the peer asserts", async () => { + const nameStorage = createMemoryStorage(); + await createNameStore(nameStorage).setPetname(namedDeviceHex, "Ada"); + renderApp({ nameStorage }); + + const rootSocket = await connectRoot(); + rootSocket.emitMessage(arrayBuffer(messageFromFrame(namedFrame))); + + const panel = await screen.findByTestId("discovered-peers"); + expect(within(panel).getByText("Ada")).toBeInTheDocument(); + expect(within(panel).getByText(SELF_ASSERTED_NAME)).toBeInTheDocument(); + }); + + it("publishes this console's own display name in a signed gossip advert once it is saved on a connected session", async () => { + renderApp(); + const rootSocket = await connectRoot(); + const sentBefore = rootSocket.sent.length; + + fireEvent.change(screen.getByLabelText(/^Your display name/), { + target: { value: "Grace" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save name" })); + + await vi.waitFor(() => { + expect(publishedSelfNames(rootSocket, sentBefore)).toEqual(["Grace"]); + }); + }); }); diff --git a/ts/packages/web-console/test/ConversationList.unit.test.tsx b/ts/packages/web-console/test/ConversationList.unit.test.tsx index b8dcf6da..a966fce5 100644 --- a/ts/packages/web-console/test/ConversationList.unit.test.tsx +++ b/ts/packages/web-console/test/ConversationList.unit.test.tsx @@ -3,9 +3,9 @@ import "@testing-library/jest-dom/vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { MantineProvider } from "@mantine/core"; import { ConversationList } from "../src/components/ConversationList.js"; import type { ConversationView } from "../src/conversations.js"; +import { WithNames, memoryNameStore } from "./names-harness.js"; import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; const DEVICE_ID_HEX_LENGTH = 64; @@ -31,16 +31,17 @@ function conversation( function renderList( props: Partial> = {}, + store = memoryNameStore(), ): ReturnType { return render( - + undefined} {...props} /> - , + , ); } @@ -113,3 +114,28 @@ describe("ConversationList", () => { expect(onSelect).toHaveBeenCalledWith(second.roomPath); }); }); + +describe("ConversationList naming", () => { + beforeEach(() => { + stubMantineJsdomGlobals(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("labels a conversation by the petname held for its peer", async () => { + const store = memoryNameStore(); + await store.setPetname("2".repeat(DEVICE_ID_HEX_LENGTH), "Ada"); + + renderList( + { conversations: [conversation("2"), conversation("3")] }, + store, + ); + + await screen.findByText("Ada"); + expect(screen.getByText("333333333333")).toBeInTheDocument(); + expect(screen.queryByText("222222222222")).toBeNull(); + }); +}); diff --git a/ts/packages/web-console/test/PeerLabel.unit.test.tsx b/ts/packages/web-console/test/PeerLabel.unit.test.tsx new file mode 100644 index 00000000..cb52d5fc --- /dev/null +++ b/ts/packages/web-console/test/PeerLabel.unit.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { PeerLabel } from "../src/components/PeerLabel.js"; +import { shortId } from "../src/peer-names.js"; +import { WithNames, memoryNameStore } from "./names-harness.js"; +import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; + +const DEVICE_ID_BYTES = 32; +const DEVICE_HEX = "cd".repeat(DEVICE_ID_BYTES); + +describe("PeerLabel", () => { + beforeEach(() => { + stubMantineJsdomGlobals(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("shows the short id for a peer nothing names", () => { + render( + + + , + ); + + expect(screen.getByText(shortId(DEVICE_HEX))).toBeInTheDocument(); + }); + + it("shows a stored petname in place of the short id", async () => { + const store = memoryNameStore(); + await store.setPetname(DEVICE_HEX, "Ada"); + + render( + + + , + ); + + await screen.findByText("Ada"); + expect(screen.queryByText(shortId(DEVICE_HEX))).toBeNull(); + }); + + it("renames a peer, persists the petname and shows it", async () => { + const store = memoryNameStore(); + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /^Rename/ })); + fireEvent.change(screen.getByLabelText(/^Petname for/), { + target: { value: "Grace" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save petname" })); + + await screen.findByText("Grace"); + expect(await store.petnames()).toEqual(new Map([[DEVICE_HEX, "Grace"]])); + }); + + it("leaves the name unchanged when a rename is cancelled", async () => { + const store = memoryNameStore(); + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /^Rename/ })); + fireEvent.change(screen.getByLabelText(/^Petname for/), { + target: { value: "Grace" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Cancel rename" })); + + expect(screen.getByText(shortId(DEVICE_HEX))).toBeInTheDocument(); + expect(await store.petnames()).toEqual(new Map()); + }); + + it("copies the full device-id", () => { + const writeText = vi.fn<(text: string) => Promise>(async () => + Promise.resolve(), + ); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /^Copy device id/ })); + + expect(writeText).toHaveBeenCalledWith(DEVICE_HEX); + }); +}); diff --git a/ts/packages/web-console/test/RoomPanel.unit.test.tsx b/ts/packages/web-console/test/RoomPanel.unit.test.tsx index 5098db92..e2c8ba48 100644 --- a/ts/packages/web-console/test/RoomPanel.unit.test.tsx +++ b/ts/packages/web-console/test/RoomPanel.unit.test.tsx @@ -9,9 +9,9 @@ import { screen, waitFor, } from "@testing-library/react"; -import { MantineProvider } from "@mantine/core"; import { RoomPanel } from "../src/components/RoomPanel.js"; import type { ConversationView } from "../src/conversations.js"; +import { WithNames } from "./names-harness.js"; import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; const DEVICE_ID_HEX_LENGTH = 64; @@ -35,7 +35,7 @@ function renderPanel( props: Partial> = {}, ): ReturnType { return render( - + Promise.resolve()} @@ -44,7 +44,7 @@ function renderPanel( onDiscard={() => undefined} {...props} /> - , + , ); } diff --git a/ts/packages/web-console/test/conversations.unit.test.ts b/ts/packages/web-console/test/conversations.unit.test.ts index ca2a0810..c7d2b59c 100644 --- a/ts/packages/web-console/test/conversations.unit.test.ts +++ b/ts/packages/web-console/test/conversations.unit.test.ts @@ -18,6 +18,7 @@ import { type RelayRoute, } from "../src/conversations.js"; import type { CapabilityToken } from "wire-mesh-core/generated/protocol"; +import { shortId } from "../src/peer-names.js"; import type { StoredMessage } from "../src/message-store.js"; const DEVICE_ID_HEX_LENGTH = 64; @@ -432,11 +433,16 @@ describe("participantsOf", () => { describe("participantLabel", () => { it("shortens each participant's device-id and joins them", () => { expect( - participantLabel({ roomPath: DM, participants: [PEER, OTHER_PEER] }), + participantLabel( + { roomPath: DM, participants: [PEER, OTHER_PEER] }, + shortId, + ), ).toBe("222222222222, 333333333333"); }); it("falls back to the room path when nobody else is in the room", () => { - expect(participantLabel({ roomPath: DM, participants: [] })).toBe(DM); + expect(participantLabel({ roomPath: DM, participants: [] }, shortId)).toBe( + DM, + ); }); }); diff --git a/ts/packages/web-console/test/name-store.unit.test.ts b/ts/packages/web-console/test/name-store.unit.test.ts new file mode 100644 index 00000000..4d9f415b --- /dev/null +++ b/ts/packages/web-console/test/name-store.unit.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage"; +import { createNameStore } from "../src/name-store.js"; + +describe("name store", () => { + it("holds a petname per device and returns them all", async () => { + const store = createNameStore(createMemoryStorage()); + await store.setPetname("aa", "Ada"); + await store.setPetname("bb", "Grace"); + + expect(await store.petnames()).toEqual( + new Map([ + ["aa", "Ada"], + ["bb", "Grace"], + ]), + ); + }); + + it("removes a petname when it is set blank", async () => { + const store = createNameStore(createMemoryStorage()); + await store.setPetname("aa", "Ada"); + await store.setPetname("aa", " "); + + expect(await store.petnames()).toEqual(new Map()); + }); + + it("keeps petnames across store instances over the same storage", async () => { + const storage = createMemoryStorage(); + await createNameStore(storage).setPetname("aa", "Ada"); + + expect(await createNameStore(storage).petnames()).toEqual( + new Map([["aa", "Ada"]]), + ); + }); + + it("keeps the console's own display name apart from petnames", async () => { + const store = createNameStore(createMemoryStorage()); + expect(await store.selfName()).toBeUndefined(); + + await store.setSelfName(" Me "); + + expect(await store.selfName()).toBe("Me"); + expect(await store.petnames()).toEqual(new Map()); + + await store.setSelfName(""); + expect(await store.selfName()).toBeUndefined(); + }); +}); diff --git a/ts/packages/web-console/test/names-harness.tsx b/ts/packages/web-console/test/names-harness.tsx new file mode 100644 index 00000000..cc5c046f --- /dev/null +++ b/ts/packages/web-console/test/names-harness.tsx @@ -0,0 +1,25 @@ +// Wraps a component in the naming context over an in-memory name store, the way App does for the real tree. + +import { MantineProvider } from "@mantine/core"; +import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage"; +import { PeerNamesProvider } from "../src/components/PeerNamesProvider.js"; +import { createNameStore } from "../src/name-store.js"; +import type { NameStore } from "../src/name-store.js"; + +export function memoryNameStore(): NameStore { + return createNameStore(createMemoryStorage()); +} + +export function WithNames({ + store = memoryNameStore(), + children, +}: Readonly<{ + store?: NameStore; + children: React.ReactNode; +}>): React.JSX.Element { + return ( + + {children} + + ); +} diff --git a/ts/packages/web-console/test/peer-names.unit.test.ts b/ts/packages/web-console/test/peer-names.unit.test.ts new file mode 100644 index 00000000..4161892a --- /dev/null +++ b/ts/packages/web-console/test/peer-names.unit.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import type { PeerAdvert } from "wire-mesh-core/generated/protocol"; +import { + MAX_NAME_LENGTH, + SHORT_ID_LENGTH, + cleanName, + labelPeer, + selfAssertedName, + selfNameExtension, + shortId, +} from "../src/peer-names.js"; +import { syntheticAdvertProof } from "./synthetic-advert.js"; +import { bytesFromHex } from "./hex.js"; + +const DEVICE_ID_BYTES = 32; +const DEVICE_HEX = "ab".repeat(DEVICE_ID_BYTES); +const OVERLONG_EXTRA = 1; + +function advertWith(extension: Record): PeerAdvert { + return { + device: bytesFromHex(DEVICE_HEX), + addresses: [], + "snapshot-seconds": 0, + ...syntheticAdvertProof(), + ...extension, + }; +} + +describe("shortId", () => { + it("is the leading characters of the device-id", () => { + expect(shortId(DEVICE_HEX)).toBe(DEVICE_HEX.slice(0, SHORT_ID_LENGTH)); + }); +}); + +describe("cleanName", () => { + it("trims and bounds a name", () => { + expect(cleanName(" Ada ")).toBe("Ada"); + expect( + cleanName("x".repeat(MAX_NAME_LENGTH + OVERLONG_EXTRA)), + ).toHaveLength(MAX_NAME_LENGTH); + }); + + it("rejects a blank name", () => { + expect(cleanName(" ")).toBeUndefined(); + }); +}); + +describe("selfAssertedName", () => { + it("reads the name from the agent/self extension", () => { + expect(selfAssertedName(advertWith(selfNameExtension("Ada")))).toBe("Ada"); + }); + + it("is undefined for an advert with no claim, a malformed claim or a blank name", () => { + expect(selfAssertedName(advertWith({}))).toBeUndefined(); + expect( + selfAssertedName(advertWith({ "agent/self": "Ada" })), + ).toBeUndefined(); + expect( + selfAssertedName(advertWith({ "agent/self": { name: 3 } })), + ).toBeUndefined(); + expect( + selfAssertedName(advertWith({ "agent/self": { name: " " } })), + ).toBeUndefined(); + }); + + it("bounds a long claimed name", () => { + const name = selfAssertedName( + advertWith(selfNameExtension("n".repeat(MAX_NAME_LENGTH * 2))), + ); + expect(name).toHaveLength(MAX_NAME_LENGTH); + }); +}); + +describe("labelPeer", () => { + it("prefers the petname and shows the peer's own claim beside it", () => { + expect(labelPeer(DEVICE_HEX, "Ada", "ada-laptop")).toEqual({ + primary: "Ada", + secondary: "ada-laptop", + source: "petname", + }); + }); + + it("omits the secondary text when the petname equals the claim or there is no claim", () => { + expect(labelPeer(DEVICE_HEX, "Ada", "Ada").secondary).toBeUndefined(); + expect(labelPeer(DEVICE_HEX, "Ada", undefined).secondary).toBeUndefined(); + }); + + it("falls back to the self-asserted name, with the short id beside it", () => { + expect(labelPeer(DEVICE_HEX, undefined, "ada-laptop")).toEqual({ + primary: "ada-laptop", + secondary: shortId(DEVICE_HEX), + source: "self", + }); + }); + + it("falls back to the short id when nothing names the peer", () => { + expect(labelPeer(DEVICE_HEX, undefined, undefined)).toEqual({ + primary: shortId(DEVICE_HEX), + secondary: undefined, + source: "id", + }); + }); +}); + +describe("selfNameExtension", () => { + it("carries the name under the key and shape the public hub's advert policy accepts", () => { + expect(selfNameExtension("Ada")).toEqual({ + "agent/self": { name: "Ada", harness: "wire-mesh-console" }, + }); + }); +}); From fcd66a78062bf6bd0a45a3beed85912d56f88e6e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Sep 2026 21:23:15 +0100 Subject: [PATCH 02/47] fix(web-console): show the short id beside a self-asserted name and strip control characters from it PeerName and the conversation labels rendered only the stranger-chosen name, so two peers claiming one name could not be told apart. cleanName now drops control and bidirectional formatting characters and truncates by code point. --- .../src/components/ConversationList.tsx | 6 +- .../web-console/src/components/PeerName.tsx | 5 +- .../web-console/src/components/RoomPanel.tsx | 5 +- ts/packages/web-console/src/peer-names.ts | 18 ++- .../web-console/test/PeerName.unit.test.tsx | 135 ++++++++++++++++++ .../web-console/test/peer-names.unit.test.ts | 34 +++++ 6 files changed, 194 insertions(+), 9 deletions(-) create mode 100644 ts/packages/web-console/test/PeerName.unit.test.tsx diff --git a/ts/packages/web-console/src/components/ConversationList.tsx b/ts/packages/web-console/src/components/ConversationList.tsx index 697fe886..413c1c38 100644 --- a/ts/packages/web-console/src/components/ConversationList.tsx +++ b/ts/packages/web-console/src/components/ConversationList.tsx @@ -3,6 +3,7 @@ import { Badge, Group, NavLink, Stack, Text } from "@mantine/core"; import { participantLabel, type ConversationView } from "../conversations.js"; import { usePeerNames } from "../hooks/use-peer-names.js"; +import { labelText } from "../peer-names.js"; export interface ConversationListProps { conversations: readonly ConversationView[]; @@ -28,9 +29,8 @@ export function ConversationList({ names.labelOf(hex).primary, + label={participantLabel(conversation, (hex) => + labelText(names.labelOf(hex)), )} description={ conversation.status === "connected" ? undefined : "offline" diff --git a/ts/packages/web-console/src/components/PeerName.tsx b/ts/packages/web-console/src/components/PeerName.tsx index 218f0f40..9bac52b3 100644 --- a/ts/packages/web-console/src/components/PeerName.tsx +++ b/ts/packages/web-console/src/components/PeerName.tsx @@ -1,6 +1,7 @@ -// A peer's name as plain inline text, for sentences and messages where PeerLabel's copy and rename controls would be out of place. +// A peer's name as plain inline text (a self-asserted name with its short id beside it), for sentences and messages where PeerLabel's copy and rename controls would be out of place. import { usePeerNames } from "../hooks/use-peer-names.js"; +import { labelText } from "../peer-names.js"; export interface PeerNameProps { deviceHex: string; @@ -9,5 +10,5 @@ export interface PeerNameProps { export function PeerName({ deviceHex, }: Readonly): React.JSX.Element { - return <>{usePeerNames().labelOf(deviceHex).primary}; + return <>{labelText(usePeerNames().labelOf(deviceHex))}; } diff --git a/ts/packages/web-console/src/components/RoomPanel.tsx b/ts/packages/web-console/src/components/RoomPanel.tsx index 4d8266a3..5a722d7b 100644 --- a/ts/packages/web-console/src/components/RoomPanel.tsx +++ b/ts/packages/web-console/src/components/RoomPanel.tsx @@ -4,6 +4,7 @@ import { Alert, Button, Divider, Group, Stack, Text } from "@mantine/core"; import { SubmitRow } from "web-ui-primitives"; import { participantLabel, type ConversationView } from "../conversations.js"; import { usePeerNames } from "../hooks/use-peer-names.js"; +import { labelText } from "../peer-names.js"; import { MessageList } from "./MessageList.js"; import { NoticesView } from "./NoticesView.js"; @@ -84,7 +85,9 @@ export function RoomPanel({ names.labelOf(hex).primary)} + peerLabel={participantLabel(view, (hex) => + labelText(names.labelOf(hex)), + )} onRetry={onRetry} onDiscard={onDiscard} /> diff --git a/ts/packages/web-console/src/peer-names.ts b/ts/packages/web-console/src/peer-names.ts index c2f3e5fc..36f7810f 100644 --- a/ts/packages/web-console/src/peer-names.ts +++ b/ts/packages/web-console/src/peer-names.ts @@ -29,11 +29,15 @@ export function shortId(deviceHex: string): string { return deviceHex.slice(0, SHORT_ID_LENGTH); } -/** A name fit to display: trimmed, non-empty, and within MAX_NAME_LENGTH. */ +/** Control characters and the bidirectional formatting characters (marks, embeddings, overrides, isolates). A self-asserted name is laid out beside neighbouring text, and these let a stranger reorder or hide that text. Other format characters such as the zero-width joiner stay, since emoji sequences need them. */ +const UNSAFE_NAME_CHARACTERS = + /[\p{Cc}\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu; + +/** A name fit to display: free of control and bidirectional formatting characters, trimmed, non-empty, and within MAX_NAME_LENGTH code points (never cutting a surrogate pair in half). */ export function cleanName(name: string): string | undefined { - const trimmed = name.trim(); + const trimmed = name.replace(UNSAFE_NAME_CHARACTERS, "").trim(); if (trimmed === "") return undefined; - return trimmed.slice(0, MAX_NAME_LENGTH); + return Array.from(trimmed).slice(0, MAX_NAME_LENGTH).join(""); } /** The display name a peer signed about itself, read from its verified advert, or undefined when it published none or a malformed one. */ @@ -69,3 +73,11 @@ export function labelPeer( } return { primary: shortId(deviceHex), secondary: undefined, source: "id" }; } + +/** A label as one line of plain text. A self-asserted name carries the short id so that two peers claiming the same name still read differently; a petname or a short id stands alone. */ +export function labelText(label: Readonly): string { + if (label.source === "self" && label.secondary !== undefined) { + return `${label.primary} (${label.secondary})`; + } + return label.primary; +} diff --git a/ts/packages/web-console/test/PeerName.unit.test.tsx b/ts/packages/web-console/test/PeerName.unit.test.tsx new file mode 100644 index 00000000..a1896e12 --- /dev/null +++ b/ts/packages/web-console/test/PeerName.unit.test.tsx @@ -0,0 +1,135 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { useEffect } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import type { DirectoryEntry } from "wire-mesh-core/domain/mesh-session"; +import type { NameStore } from "../src/name-store.js"; +import { PeerName } from "../src/components/PeerName.js"; +import { usePeerNames } from "../src/hooks/use-peer-names.js"; +import { selfNameExtension, shortId } from "../src/peer-names.js"; +import { deviceIdFromFillHex } from "./hex.js"; +import { WithNames, memoryNameStore } from "./names-harness.js"; +import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; +import { syntheticAdvertProof } from "./synthetic-advert.js"; + +const DEVICE_ID_HEX_LENGTH = 64; +const FIRST_FILL = "ab"; +const SECOND_FILL = "cd"; +const FIRST_HEX = FIRST_FILL.repeat(DEVICE_ID_HEX_LENGTH / FIRST_FILL.length); +const SECOND_HEX = SECOND_FILL.repeat( + DEVICE_ID_HEX_LENGTH / SECOND_FILL.length, +); +const CLAIMED_NAME = "Alice"; + +function entry( + fill: string, + extension: Record, +): DirectoryEntry { + return { + device: deviceIdFromFillHex(fill), + advert: { + device: deviceIdFromFillHex(fill), + addresses: [], + "snapshot-seconds": 0, + ...syntheticAdvertProof(), + ...extension, + }, + }; +} + +/** Feeds a directory to the naming context the way a connection panel does, and renders each given device's PeerName. */ +function Observed({ + directory, + devices, +}: Readonly<{ + directory: readonly DirectoryEntry[]; + devices: readonly string[]; +}>): React.JSX.Element { + const { observeDirectory } = usePeerNames(); + useEffect(() => { + observeDirectory(directory); + }, [directory, observeDirectory]); + return ( +
    + {devices.map((hex) => ( +
  • + +
  • + ))} +
+ ); +} + +function renderObserved( + directory: readonly DirectoryEntry[], + store: Readonly = memoryNameStore(), +): ReturnType { + return render( + + + , + ); +} + +describe("PeerName", () => { + beforeEach(() => { + stubMantineJsdomGlobals(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("tells two peers claiming the same name apart by their short ids", async () => { + renderObserved([ + entry(FIRST_FILL, selfNameExtension(CLAIMED_NAME)), + entry(SECOND_FILL, selfNameExtension(CLAIMED_NAME)), + ]); + + await vi.waitFor(() => { + expect(screen.getByTestId(FIRST_HEX)).toHaveTextContent( + `${CLAIMED_NAME} (${shortId(FIRST_HEX)})`, + ); + }); + expect(screen.getByTestId(SECOND_HEX)).toHaveTextContent( + `${CLAIMED_NAME} (${shortId(SECOND_HEX)})`, + ); + }); + + it("shows a petname alone, since the viewer chose it", async () => { + const store = memoryNameStore(); + await store.setPetname(FIRST_HEX, "Ada"); + renderObserved([entry(FIRST_FILL, selfNameExtension(CLAIMED_NAME))], store); + + await vi.waitFor(() => { + expect(screen.getByTestId(FIRST_HEX)).toHaveTextContent(/^Ada$/); + }); + }); + + it("drops a name once a later advert from the peer no longer asserts one", async () => { + const { rerender } = renderObserved([ + entry(FIRST_FILL, selfNameExtension(CLAIMED_NAME)), + ]); + await vi.waitFor(() => { + expect(screen.getByTestId(FIRST_HEX)).toHaveTextContent(CLAIMED_NAME); + }); + + rerender( + + + , + ); + + await vi.waitFor(() => { + expect(screen.getByTestId(FIRST_HEX)).toHaveTextContent( + new RegExp(`^${shortId(FIRST_HEX)}$`), + ); + }); + }); +}); diff --git a/ts/packages/web-console/test/peer-names.unit.test.ts b/ts/packages/web-console/test/peer-names.unit.test.ts index 4161892a..a15f8f77 100644 --- a/ts/packages/web-console/test/peer-names.unit.test.ts +++ b/ts/packages/web-console/test/peer-names.unit.test.ts @@ -5,6 +5,7 @@ import { SHORT_ID_LENGTH, cleanName, labelPeer, + labelText, selfAssertedName, selfNameExtension, shortId, @@ -40,6 +41,24 @@ describe("cleanName", () => { ).toHaveLength(MAX_NAME_LENGTH); }); + it("strips control and bidirectional formatting characters", () => { + expect(cleanName("A\u202Eda\u0000\u2066!\u200F")).toBe("Ada!"); + expect(cleanName("\u202E\u0007")).toBeUndefined(); + }); + + it("keeps the zero-width joiner that emoji sequences need", () => { + const family = "\u{1F468}\u200D\u{1F469}\u200D\u{1F467}"; + expect(cleanName(family)).toBe(family); + }); + + it("bounds by code point, never cutting a surrogate pair", () => { + const name = cleanName( + "\u{1F600}".repeat(MAX_NAME_LENGTH + OVERLONG_EXTRA), + ); + expect(Array.from(name ?? "")).toHaveLength(MAX_NAME_LENGTH); + expect(name).toBe("\u{1F600}".repeat(MAX_NAME_LENGTH)); + }); + it("rejects a blank name", () => { expect(cleanName(" ")).toBeUndefined(); }); @@ -109,3 +128,18 @@ describe("selfNameExtension", () => { }); }); }); + +describe("labelText", () => { + it("puts the short id beside a self-asserted name", () => { + expect(labelText(labelPeer(DEVICE_HEX, undefined, "Ada"))).toBe( + `Ada (${shortId(DEVICE_HEX)})`, + ); + }); + + it("shows a petname or a short id alone", () => { + expect(labelText(labelPeer(DEVICE_HEX, "Grace", "Ada"))).toBe("Grace"); + expect(labelText(labelPeer(DEVICE_HEX, undefined, undefined))).toBe( + shortId(DEVICE_HEX), + ); + }); +}); From ed980264488efabbf7c986455eed015e83179421 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Sep 2026 21:23:15 +0100 Subject: [PATCH 03/47] fix(web-console): retract a published display name when it is cleared and surface publish failures --- .../src/components/ConnectionPanel.tsx | 36 ++++++++++++++----- .../web-console/test/App.unit.test.tsx | 35 ++++++++++++++++++ 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/ts/packages/web-console/src/components/ConnectionPanel.tsx b/ts/packages/web-console/src/components/ConnectionPanel.tsx index 8c0e6d4f..9e565ede 100644 --- a/ts/packages/web-console/src/components/ConnectionPanel.tsx +++ b/ts/packages/web-console/src/components/ConnectionPanel.tsx @@ -1,13 +1,12 @@ // One open relay/hub connection's own UI: status line, ping/disconnect controls, peer directory, and frame log. All behaviour lives in wire-mesh-core's own mesh-session domain module; this component only renders whatever useMeshSessionEvents last reported and forwards clicks back onto the session. import { Alert, Button, Card, Group, Table, Text } from "@mantine/core"; -import { useEffect, useState } from "react"; import type { MeshSession, SessionEvent, } from "wire-mesh-core/domain/mesh-session"; import type { DeviceId } from "wire-mesh-core/generated/protocol"; -import { useEffect } from "react"; +import { useEffect, useRef, useState } from "react"; import { useMeshSessionEvents } from "../hooks/use-mesh-session-events.js"; import { browserPermissions, @@ -119,13 +118,29 @@ export function ConnectionPanel({ observeDirectory(event.directory); } }, [event, observeDirectory]); - // A session's initial self-advert carries no extensions, so this console's own display name is published by re-sending it once the link is up, and again whenever the name changes. + // A session's initial self-advert carries no extensions, so this console's own display name is published by re-sending it once the link is up, and again whenever the name changes. Clearing the name re-sends the advert with no extensions, which is what retracts the earlier claim; that is only needed once a name has been published on this session. + const namePublished = useRef(false); + const [publishFailure, setPublishFailure] = useState( + undefined, + ); useEffect(() => { - if (connected && selfName !== undefined) { - session.sendGossipUpdate(selfNameExtension(selfName)).catch(() => { - // The link dropped between the status read and the send; this effect runs again when the session reconnects, which republishes the name. - }); - } + if (!connected) return; + if (selfName === undefined && !namePublished.current) return; + namePublished.current = selfName !== undefined; + session + .sendGossipUpdate( + selfName === undefined ? undefined : selfNameExtension(selfName), + ) + .then( + () => { + setPublishFailure(undefined); + }, + (error: unknown) => { + setPublishFailure( + error instanceof Error ? error.message : String(error), + ); + }, + ); }, [connected, selfName, session]); const status = event === undefined ? "idle" : describeStatus(event); const directory = event?.directory ?? []; @@ -158,6 +173,11 @@ export function ConnectionPanel({ {blocked} )} + {publishFailure !== undefined && ( + + {publishFailure} + + )} Peer directory diff --git a/ts/packages/web-console/test/App.unit.test.tsx b/ts/packages/web-console/test/App.unit.test.tsx index 2b9d7cc3..49c703c4 100644 --- a/ts/packages/web-console/test/App.unit.test.tsx +++ b/ts/packages/web-console/test/App.unit.test.tsx @@ -197,6 +197,19 @@ function publishedSelfNames( }); } +/** The display-name claim of each advert in every gossip frame a socket sent after `from` sends, with undefined for an advert that asserts none. */ +function gossipedClaims( + socket: Readonly, + from: number, +): (string | undefined)[] { + return socket.sent.slice(from).flatMap((data) => { + const frame = decodeMessage(data); + return frame.type === "gossip" + ? frame.peers.map((advert) => selfAssertedName(advert)) + : []; + }); +} + describe("App", () => { beforeEach(() => { sockets = []; @@ -468,4 +481,26 @@ describe("App", () => { expect(publishedSelfNames(rootSocket, sentBefore)).toEqual(["Grace"]); }); }); + + it("retracts a published display name with an advert that carries none once the name is cleared", async () => { + renderApp(); + const rootSocket = await connectRoot(); + fireEvent.change(screen.getByLabelText(/^Your display name/), { + target: { value: "Grace" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save name" })); + await vi.waitFor(() => { + expect(publishedSelfNames(rootSocket, 0)).toEqual(["Grace"]); + }); + const sentBefore = rootSocket.sent.length; + + fireEvent.change(screen.getByLabelText(/^Your display name/), { + target: { value: "" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save name" })); + + await vi.waitFor(() => { + expect(gossipedClaims(rootSocket, sentBefore)).toEqual([undefined]); + }); + }); }); From c7095a727b3ec37a82ab618cf85e63efd1552824 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Sep 2026 21:23:15 +0100 Subject: [PATCH 04/47] fix(web-console): drop a peer's name once it stops advertising one and surface name storage failures --- .../src/components/PeerNamesProvider.tsx | 67 +++++++++++++++---- .../test/PeerNamesProvider.unit.test.tsx | 37 ++++++++++ 2 files changed, 90 insertions(+), 14 deletions(-) create mode 100644 ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx diff --git a/ts/packages/web-console/src/components/PeerNamesProvider.tsx b/ts/packages/web-console/src/components/PeerNamesProvider.tsx index ace1a429..b8488d5e 100644 --- a/ts/packages/web-console/src/components/PeerNamesProvider.tsx +++ b/ts/packages/web-console/src/components/PeerNamesProvider.tsx @@ -1,6 +1,7 @@ // Holds the petnames loaded from the console's name store and the self display names observed in directories, and serves them through PeerNamesContext. import { useCallback, useEffect, useMemo, useState } from "react"; +import { Alert } from "@mantine/core"; import type { DirectoryEntry } from "wire-mesh-core/domain/mesh-session"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import type { NameStore } from "../name-store.js"; @@ -24,26 +25,55 @@ export function PeerNamesProvider({ new Map(), ); const [selfName, setOwnName] = useState(undefined); + const [storageFailure, setStorageFailure] = useState( + undefined, + ); + + /** Runs one storage operation and shows its failure to the person, since names are read and written from event handlers with no caller positioned to handle a rejection. */ + const reportFailure = useCallback( + async (operation: () => Promise): Promise => { + try { + await operation(); + setStorageFailure(undefined); + } catch (error: unknown) { + setStorageFailure( + error instanceof Error ? error.message : String(error), + ); + } + }, + [], + ); useEffect(() => { - void store.petnames().then(setPetnames); - void store.selfName().then(setOwnName); + Promise.all([store.petnames(), store.selfName()]).then( + ([loadedPetnames, loadedSelfName]) => { + setPetnames(loadedPetnames); + setOwnName(loadedSelfName); + }, + (error: unknown) => { + setStorageFailure( + error instanceof Error ? error.message : String(error), + ); + }, + ); }, [store]); const rename = useCallback( - async (deviceHex: string, name: string): Promise => { - await store.setPetname(deviceHex, name); - setPetnames(await store.petnames()); - }, - [store], + async (deviceHex: string, name: string): Promise => + reportFailure(async () => { + await store.setPetname(deviceHex, name); + setPetnames(await store.petnames()); + }), + [store, reportFailure], ); const setSelfName = useCallback( - async (name: string): Promise => { - await store.setSelfName(name); - setOwnName(await store.selfName()); - }, - [store], + async (name: string): Promise => + reportFailure(async () => { + await store.setSelfName(name); + setOwnName(await store.selfName()); + }), + [store, reportFailure], ); const observeDirectory = useCallback( @@ -53,8 +83,12 @@ export function PeerNamesProvider({ for (const entry of directory) { const name = selfAssertedName(entry.advert); const hex = deviceIdToHex(entry.device); - if (name !== undefined && current.get(hex) !== name) { - next ??= new Map(current); + if (current.get(hex) === name) continue; + next ??= new Map(current); + // A later advert without a valid name retracts the earlier claim. + if (name === undefined) { + next.delete(hex); + } else { next.set(hex, name); } } @@ -79,6 +113,11 @@ export function PeerNamesProvider({ return ( + {storageFailure !== undefined && ( + + {storageFailure} + + )} {children} ); diff --git a/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx b/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx new file mode 100644 index 00000000..b6e005d9 --- /dev/null +++ b/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import type { NameStore } from "../src/name-store.js"; +import { WithNames, memoryNameStore } from "./names-harness.js"; +import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; + +const STORAGE_FAILURE = "the origin's storage quota is exhausted"; + +describe("PeerNamesProvider", () => { + beforeEach(() => { + stubMantineJsdomGlobals(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("shows the reason when the saved names cannot be read", async () => { + const store: NameStore = { + ...memoryNameStore(), + petnames: async () => Promise.reject(new Error(STORAGE_FAILURE)), + }; + + render( + +

child

+
, + ); + + expect(await screen.findByText(STORAGE_FAILURE)).toBeInTheDocument(); + expect(screen.getByText("child")).toBeInTheDocument(); + }); +}); From ef08dc9a3b5b8ee6d424d87122f9d643ae8f8ba7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 08:24:41 +0100 Subject: [PATCH 05/47] fix(web-console): mark a self-asserted name as the peer's own claim and trim a name after the length cut A self name is shown in italics under a 'calls itself' marker and flagged when it equals a petname the viewer holds for any device. cleanName trims after truncating so a cut after a space leaves no trailing space, and the Save button compares the draft with its cleaned form. --- .../web-console/src/components/PeerLabel.tsx | 14 +++- .../src/components/PeerNamesProvider.tsx | 19 ++++- .../src/components/SelfNameField.tsx | 4 +- ts/packages/web-console/src/peer-names.ts | 23 +++++- .../web-console/test/PeerLabel.unit.test.tsx | 73 ++++++++++++++++++- .../test/SelfNameField.unit.test.tsx | 62 ++++++++++++++++ .../web-console/test/peer-names.unit.test.ts | 58 +++++++++++---- 7 files changed, 228 insertions(+), 25 deletions(-) create mode 100644 ts/packages/web-console/test/SelfNameField.unit.test.tsx diff --git a/ts/packages/web-console/src/components/PeerLabel.tsx b/ts/packages/web-console/src/components/PeerLabel.tsx index b8467234..25466706 100644 --- a/ts/packages/web-console/src/components/PeerLabel.tsx +++ b/ts/packages/web-console/src/components/PeerLabel.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { ActionIcon, CopyButton, + Badge, Group, Text, TextInput, @@ -68,13 +69,24 @@ export function PeerLabel({ return (
+ {label.source === "self" && ( + + calls itself + + )} {label.primary} + {label.matchesPetname && ( + + same as a name you set + + )} {label.secondary !== undefined && ( new Set(petnames.values()), [petnames]); + const value = useMemo( () => ({ labelOf: (deviceHex) => - labelPeer(deviceHex, petnames.get(deviceHex), selfNames.get(deviceHex)), + labelPeer( + deviceHex, + petnames.get(deviceHex), + selfNames.get(deviceHex), + heldPetnames, + ), petnameOf: (deviceHex) => petnames.get(deviceHex), rename, selfName, setSelfName, observeDirectory, }), - [petnames, selfNames, selfName, rename, setSelfName, observeDirectory], + [ + petnames, + heldPetnames, + selfNames, + selfName, + rename, + setSelfName, + observeDirectory, + ], ); return ( diff --git a/ts/packages/web-console/src/components/SelfNameField.tsx b/ts/packages/web-console/src/components/SelfNameField.tsx index 3e5d9ae5..7807a76d 100644 --- a/ts/packages/web-console/src/components/SelfNameField.tsx +++ b/ts/packages/web-console/src/components/SelfNameField.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { Button, Group, TextInput } from "@mantine/core"; import { usePeerNames } from "../hooks/use-peer-names.js"; -import { MAX_NAME_LENGTH } from "../peer-names.js"; +import { MAX_NAME_LENGTH, cleanName } from "../peer-names.js"; export function SelfNameField(): React.JSX.Element { const { selfName, setSelfName } = usePeerNames(); @@ -33,7 +33,7 @@ export function SelfNameField(): React.JSX.Element { diff --git a/ts/packages/web-console/src/peer-names.ts b/ts/packages/web-console/src/peer-names.ts index 36f7810f..66948d47 100644 --- a/ts/packages/web-console/src/peer-names.ts +++ b/ts/packages/web-console/src/peer-names.ts @@ -23,6 +23,8 @@ export interface PeerLabel { /** What to show beside the primary text, or undefined when the primary is already the short id. A petname is shown with the peer's own claim so a renamed peer can still be recognised; a self name is shown with the short id so two peers claiming one name can be told apart. */ secondary: string | undefined; source: PeerLabelSource; + /** True when the primary text is a self-asserted name that equals a petname this viewer holds for some device, so the peer may be passing itself off as someone the viewer already named. Always false for a petname or a short id. */ + matchesPetname: boolean; } export function shortId(deviceHex: string): string { @@ -33,11 +35,11 @@ export function shortId(deviceHex: string): string { const UNSAFE_NAME_CHARACTERS = /[\p{Cc}\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu; -/** A name fit to display: free of control and bidirectional formatting characters, trimmed, non-empty, and within MAX_NAME_LENGTH code points (never cutting a surrogate pair in half). */ +/** A name fit to display: free of control and bidirectional formatting characters, non-empty, within MAX_NAME_LENGTH code points (never cutting a surrogate pair in half), and trimmed after that cut so a cut that lands after a space leaves no trailing space. */ export function cleanName(name: string): string | undefined { const trimmed = name.replace(UNSAFE_NAME_CHARACTERS, "").trim(); if (trimmed === "") return undefined; - return Array.from(trimmed).slice(0, MAX_NAME_LENGTH).join(""); + return Array.from(trimmed).slice(0, MAX_NAME_LENGTH).join("").trimEnd(); } /** The display name a peer signed about itself, read from its verified advert, or undefined when it published none or a malformed one. */ @@ -55,10 +57,12 @@ export function selfNameExtension(name: string): Record { return { [SELF_ADVERT_KEY]: { name, harness: CONSOLE_HARNESS } }; } +/** Labels a peer. `heldPetnames` is every petname this viewer holds, for any device, so a self-asserted name that copies one can be flagged. */ export function labelPeer( deviceHex: string, petname: string | undefined, selfName: string | undefined, + heldPetnames: ReadonlySet, ): PeerLabel { if (petname !== undefined) { return { @@ -66,12 +70,23 @@ export function labelPeer( secondary: selfName !== undefined && selfName !== petname ? selfName : undefined, source: "petname", + matchesPetname: false, }; } if (selfName !== undefined) { - return { primary: selfName, secondary: shortId(deviceHex), source: "self" }; + return { + primary: selfName, + secondary: shortId(deviceHex), + source: "self", + matchesPetname: heldPetnames.has(selfName), + }; } - return { primary: shortId(deviceHex), secondary: undefined, source: "id" }; + return { + primary: shortId(deviceHex), + secondary: undefined, + source: "id", + matchesPetname: false, + }; } /** A label as one line of plain text. A self-asserted name carries the short id so that two peers claiming the same name still read differently; a petname or a short id stands alone. */ diff --git a/ts/packages/web-console/test/PeerLabel.unit.test.tsx b/ts/packages/web-console/test/PeerLabel.unit.test.tsx index cb52d5fc..e3225b94 100644 --- a/ts/packages/web-console/test/PeerLabel.unit.test.tsx +++ b/ts/packages/web-console/test/PeerLabel.unit.test.tsx @@ -2,14 +2,43 @@ import "@testing-library/jest-dom/vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useEffect } from "react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { deviceIdFromHex } from "wire-mesh-core/domain/device-id"; import { PeerLabel } from "../src/components/PeerLabel.js"; -import { shortId } from "../src/peer-names.js"; +import { usePeerNames } from "../src/hooks/use-peer-names.js"; +import { selfNameExtension, shortId } from "../src/peer-names.js"; +import { syntheticAdvertProof } from "./synthetic-advert.js"; import { WithNames, memoryNameStore } from "./names-harness.js"; import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; const DEVICE_ID_BYTES = 32; const DEVICE_HEX = "cd".repeat(DEVICE_ID_BYTES); +const OTHER_HEX = "ef".repeat(DEVICE_ID_BYTES); + +/** Feeds the naming context a directory in which `deviceHex` asserts `selfName` about itself, as a connected node's directory would. */ +function SelfClaim({ + deviceHex, + selfName, +}: Readonly<{ deviceHex: string; selfName: string }>): null { + const { observeDirectory } = usePeerNames(); + useEffect(() => { + const device = deviceIdFromHex(deviceHex); + observeDirectory([ + { + device, + advert: { + device, + addresses: [], + "snapshot-seconds": 0, + ...syntheticAdvertProof(), + ...selfNameExtension(selfName), + }, + }, + ]); + }, [deviceHex, selfName, observeDirectory]); + return null; +} describe("PeerLabel", () => { beforeEach(() => { @@ -96,4 +125,46 @@ describe("PeerLabel", () => { expect(writeText).toHaveBeenCalledWith(DEVICE_HEX); }); + + it("marks a self-asserted name as the peer's own claim, apart from a petname", async () => { + render( + + + + , + ); + + await screen.findByText("Alice"); + expect(screen.getByText("calls itself")).toBeInTheDocument(); + expect(screen.getByText(shortId(DEVICE_HEX))).toBeInTheDocument(); + expect(screen.queryByText("same as a name you set")).toBeNull(); + }); + + it("does not mark a petname as a claim", async () => { + const store = memoryNameStore(); + await store.setPetname(DEVICE_HEX, "Alice"); + render( + + + , + ); + + await screen.findByText("Alice"); + expect(screen.queryByText("calls itself")).toBeNull(); + }); + + it("flags a self-asserted name that copies a petname held for another device", async () => { + const store = memoryNameStore(); + await store.setPetname(OTHER_HEX, "Alice"); + render( + + + + , + ); + + expect( + await screen.findByText("same as a name you set"), + ).toBeInTheDocument(); + }); }); diff --git a/ts/packages/web-console/test/SelfNameField.unit.test.tsx b/ts/packages/web-console/test/SelfNameField.unit.test.tsx new file mode 100644 index 00000000..21fead38 --- /dev/null +++ b/ts/packages/web-console/test/SelfNameField.unit.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { SelfNameField } from "../src/components/SelfNameField.js"; +import { WithNames, memoryNameStore } from "./names-harness.js"; +import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; + +describe("SelfNameField", () => { + beforeEach(() => { + stubMantineJsdomGlobals(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("keeps Save disabled for a draft that cleans to the name already saved", async () => { + const store = memoryNameStore(); + await store.setSelfName("Ada"); + render( + + + , + ); + const field = await screen.findByDisplayValue("Ada"); + + fireEvent.change(field, { target: { value: "Ada‮" } }); + + expect(screen.getByRole("button", { name: "Save name" })).toBeDisabled(); + }); + + it("keeps Save disabled for a draft of only stripped characters when no name is saved", () => { + render( + + + , + ); + + fireEvent.change(screen.getByLabelText(/^Your display name/), { + target: { value: "‮⁦" }, + }); + + expect(screen.getByRole("button", { name: "Save name" })).toBeDisabled(); + }); + + it("enables Save for a draft that cleans to a different name", () => { + render( + + + , + ); + + fireEvent.change(screen.getByLabelText(/^Your display name/), { + target: { value: "Grace" }, + }); + + expect(screen.getByRole("button", { name: "Save name" })).toBeEnabled(); + }); +}); diff --git a/ts/packages/web-console/test/peer-names.unit.test.ts b/ts/packages/web-console/test/peer-names.unit.test.ts index a15f8f77..bf50fa62 100644 --- a/ts/packages/web-console/test/peer-names.unit.test.ts +++ b/ts/packages/web-console/test/peer-names.unit.test.ts @@ -59,6 +59,11 @@ describe("cleanName", () => { expect(name).toBe("\u{1F600}".repeat(MAX_NAME_LENGTH)); }); + it("leaves no trailing space when the length cut lands after a space", () => { + const name = `${"a".repeat(MAX_NAME_LENGTH - 1)} b`; + expect(cleanName(name)).toBe("a".repeat(MAX_NAME_LENGTH - 1)); + }); + it("rejects a blank name", () => { expect(cleanName(" ")).toBeUndefined(); }); @@ -90,35 +95,56 @@ describe("selfAssertedName", () => { }); }); +const NO_PETNAMES: ReadonlySet = new Set(); + describe("labelPeer", () => { it("prefers the petname and shows the peer's own claim beside it", () => { - expect(labelPeer(DEVICE_HEX, "Ada", "ada-laptop")).toEqual({ + expect(labelPeer(DEVICE_HEX, "Ada", "ada-laptop", NO_PETNAMES)).toEqual({ primary: "Ada", secondary: "ada-laptop", source: "petname", + matchesPetname: false, }); }); it("omits the secondary text when the petname equals the claim or there is no claim", () => { - expect(labelPeer(DEVICE_HEX, "Ada", "Ada").secondary).toBeUndefined(); - expect(labelPeer(DEVICE_HEX, "Ada", undefined).secondary).toBeUndefined(); + expect( + labelPeer(DEVICE_HEX, "Ada", "Ada", NO_PETNAMES).secondary, + ).toBeUndefined(); + expect( + labelPeer(DEVICE_HEX, "Ada", undefined, NO_PETNAMES).secondary, + ).toBeUndefined(); }); it("falls back to the self-asserted name, with the short id beside it", () => { - expect(labelPeer(DEVICE_HEX, undefined, "ada-laptop")).toEqual({ - primary: "ada-laptop", - secondary: shortId(DEVICE_HEX), - source: "self", - }); + expect(labelPeer(DEVICE_HEX, undefined, "ada-laptop", NO_PETNAMES)).toEqual( + { + primary: "ada-laptop", + secondary: shortId(DEVICE_HEX), + source: "self", + matchesPetname: false, + }, + ); }); it("falls back to the short id when nothing names the peer", () => { - expect(labelPeer(DEVICE_HEX, undefined, undefined)).toEqual({ + expect(labelPeer(DEVICE_HEX, undefined, undefined, NO_PETNAMES)).toEqual({ primary: shortId(DEVICE_HEX), secondary: undefined, source: "id", + matchesPetname: false, }); }); + + it("flags a self-asserted name that copies a petname held for any device", () => { + const held = new Set(["Ada"]); + expect(labelPeer(DEVICE_HEX, undefined, "Ada", held).matchesPetname).toBe( + true, + ); + expect(labelPeer(DEVICE_HEX, undefined, "Grace", held).matchesPetname).toBe( + false, + ); + }); }); describe("selfNameExtension", () => { @@ -131,15 +157,17 @@ describe("selfNameExtension", () => { describe("labelText", () => { it("puts the short id beside a self-asserted name", () => { - expect(labelText(labelPeer(DEVICE_HEX, undefined, "Ada"))).toBe( - `Ada (${shortId(DEVICE_HEX)})`, - ); + expect( + labelText(labelPeer(DEVICE_HEX, undefined, "Ada", NO_PETNAMES)), + ).toBe(`Ada (${shortId(DEVICE_HEX)})`); }); it("shows a petname or a short id alone", () => { - expect(labelText(labelPeer(DEVICE_HEX, "Grace", "Ada"))).toBe("Grace"); - expect(labelText(labelPeer(DEVICE_HEX, undefined, undefined))).toBe( - shortId(DEVICE_HEX), + expect(labelText(labelPeer(DEVICE_HEX, "Grace", "Ada", NO_PETNAMES))).toBe( + "Grace", ); + expect( + labelText(labelPeer(DEVICE_HEX, undefined, undefined, NO_PETNAMES)), + ).toBe(shortId(DEVICE_HEX)); }); }); From cee439551bdfdc4289d16d506d4834327c9ce1e9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 08:24:42 +0100 Subject: [PATCH 06/47] fix(web-console): apply petname and self-name writes in the order they were made --- .../src/components/PeerNamesProvider.tsx | 27 +++++++---- .../test/PeerNamesProvider.unit.test.tsx | 46 ++++++++++++++++++- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/ts/packages/web-console/src/components/PeerNamesProvider.tsx b/ts/packages/web-console/src/components/PeerNamesProvider.tsx index 0b8fddec..6154e477 100644 --- a/ts/packages/web-console/src/components/PeerNamesProvider.tsx +++ b/ts/packages/web-console/src/components/PeerNamesProvider.tsx @@ -1,6 +1,6 @@ // Holds the petnames loaded from the console's name store and the self display names observed in directories, and serves them through PeerNamesContext. -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert } from "@mantine/core"; import type { DirectoryEntry } from "wire-mesh-core/domain/mesh-session"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; @@ -29,17 +29,24 @@ export function PeerNamesProvider({ undefined, ); - /** Runs one storage operation and shows its failure to the person, since names are read and written from event handlers with no caller positioned to handle a rejection. */ + /** The tail of the chain of storage operations. Each write is followed by a re-read, and two overlapping ones could settle out of order and leave the older snapshot in state, so operations run one after another. Every link settles, so a failure never blocks the next. */ + const storageQueue = useRef>(Promise.resolve()); + + /** Queues one storage operation behind the earlier ones and shows its failure to the person, since names are read and written from event handlers with no caller positioned to handle a rejection. */ const reportFailure = useCallback( async (operation: () => Promise): Promise => { - try { - await operation(); - setStorageFailure(undefined); - } catch (error: unknown) { - setStorageFailure( - error instanceof Error ? error.message : String(error), - ); - } + const run = async (): Promise => { + try { + await operation(); + setStorageFailure(undefined); + } catch (error: unknown) { + setStorageFailure( + error instanceof Error ? error.message : String(error), + ); + } + }; + storageQueue.current = storageQueue.current.then(run); + await storageQueue.current; }, [], ); diff --git a/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx b/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx index b6e005d9..3cb60179 100644 --- a/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx +++ b/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx @@ -2,11 +2,16 @@ import "@testing-library/jest-dom/vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { usePeerNames } from "../src/hooks/use-peer-names.js"; +import type { PeerNames } from "../src/hooks/use-peer-names.js"; import type { NameStore } from "../src/name-store.js"; import { WithNames, memoryNameStore } from "./names-harness.js"; import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; +const DEVICE_ID_BYTES = 32; +const DEVICE_HEX = "ab".repeat(DEVICE_ID_BYTES); +const SLOW_WRITE_MS = 30; const STORAGE_FAILURE = "the origin's storage quota is exhausted"; describe("PeerNamesProvider", () => { @@ -34,4 +39,43 @@ describe("PeerNamesProvider", () => { expect(await screen.findByText(STORAGE_FAILURE)).toBeInTheDocument(); expect(screen.getByText("child")).toBeInTheDocument(); }); + + it("applies overlapping renames in the order they were made, whatever order the storage answers in", async () => { + const inner = memoryNameStore(); + // The first write is slow, so an unserialised second rename would finish first and the first one would then land last and win. + let writes = 0; + const store: NameStore = { + ...inner, + setPetname: async (deviceHex, name) => { + writes += 1; + if (writes === 1) { + await new Promise((resolve) => { + setTimeout(resolve, SLOW_WRITE_MS); + }); + } + await inner.setPetname(deviceHex, name); + }, + }; + let names: PeerNames | undefined; + function Capture(): null { + names = usePeerNames(); + return null; + } + render( + + + , + ); + await act(async () => { + await Promise.resolve(); + }); + + await act(async () => { + const first = names?.rename(DEVICE_HEX, "First"); + const second = names?.rename(DEVICE_HEX, "Second"); + await Promise.all([first, second]); + }); + + expect(names?.petnameOf(DEVICE_HEX)).toBe("Second"); + }); }); From 97911f5d7d63a2ab592ed9a711bc113128ae2674 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 09:50:21 +0100 Subject: [PATCH 07/47] test(web-console): render the local-network panel test inside the names context ConnectionPanel now reads peer names through PeerNamesProvider, so the local-network explanation test, written before names existed, has to mount the panel the way the app does. --- ts/packages/web-console/test/ConnectionPanel.unit.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ts/packages/web-console/test/ConnectionPanel.unit.test.tsx b/ts/packages/web-console/test/ConnectionPanel.unit.test.tsx index 1c62ef69..9fd2bb68 100644 --- a/ts/packages/web-console/test/ConnectionPanel.unit.test.tsx +++ b/ts/packages/web-console/test/ConnectionPanel.unit.test.tsx @@ -3,13 +3,13 @@ import "@testing-library/jest-dom/vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; -import { MantineProvider } from "@mantine/core"; import { createMeshSession, type MeshSession, } from "wire-mesh-core/domain/mesh-session"; import type { Transport } from "wire-mesh-core/ports/transport"; import { createWebCryptoIdentity } from "../src/adapters/web-crypto-identity.js"; +import { WithNames } from "./names-harness.js"; import { ConnectionPanel } from "../src/components/ConnectionPanel.js"; import type { PermissionQuerier } from "../src/local-network.js"; import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; @@ -44,7 +44,7 @@ async function renderPanel( ): Promise { const session = await failingSession(); render( - + - , + , ); } From 9d4034ac1dbc48adc4a75d4724091e6371b5fe0d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Sep 2026 20:13:36 +0100 Subject: [PATCH 08/47] feat(web-console): confirm first-use certificates, warn on changes, show peer provenance An address that carries certificate pins is assessed against the certificate memory before it is dialled: nothing remembered asks the user to trust the presented certificate, a disjoint remembered set raises a change warning that must be accepted, and an overlapping set (a rotation) connects silently. A node that later announces certificates sharing nothing with the remembered ones is reported rather than overwritten silently. Each discovered-peer row names the connection whose directory gossiped it and, when known, the node that vouched for that connection. --- ts/packages/web-console/src/App.tsx | 73 +++++++-- .../web-console/src/certificate-trust.ts | 102 ++++++++++++ .../src/components/CertificateTrustPanel.tsx | 125 +++++++++++++++ .../src/components/DiscoveredPeersPanel.tsx | 23 +++ .../src/hooks/use-certificate-trust.ts | 105 +++++++++++++ .../web-console/test/App.unit.test.tsx | 147 +++++++++++++++++- .../test/CertificateTrustPanel.unit.test.tsx | 123 +++++++++++++++ .../test/certificate-trust.unit.test.ts | 114 ++++++++++++++ 8 files changed, 797 insertions(+), 15 deletions(-) create mode 100644 ts/packages/web-console/src/certificate-trust.ts create mode 100644 ts/packages/web-console/src/components/CertificateTrustPanel.tsx create mode 100644 ts/packages/web-console/src/hooks/use-certificate-trust.ts create mode 100644 ts/packages/web-console/test/CertificateTrustPanel.unit.test.tsx create mode 100644 ts/packages/web-console/test/certificate-trust.unit.test.ts diff --git a/ts/packages/web-console/src/App.tsx b/ts/packages/web-console/src/App.tsx index 5ade11f7..216c9fbe 100644 --- a/ts/packages/web-console/src/App.tsx +++ b/ts/packages/web-console/src/App.tsx @@ -34,7 +34,12 @@ import { createWebrtcNegotiator } from "./webrtc-negotiation.js"; import type { WebrtcNegotiator } from "./webrtc-negotiation.js"; import { ConnectionPanel, deviceHex } from "./components/ConnectionPanel.js"; import { DiscoveredPeersPanel } from "./components/DiscoveredPeersPanel.js"; -import type { DiscoveredPeerRow } from "./components/DiscoveredPeersPanel.js"; +import type { + DiscoveredPeerRow, + DiscoveredVia, +} from "./components/DiscoveredPeersPanel.js"; +import { CertificateTrustPanel } from "./components/CertificateTrustPanel.js"; +import { useCertificateTrust } from "./hooks/use-certificate-trust.js"; import { PeerNamesProvider } from "./components/PeerNamesProvider.js"; import { PeerName } from "./components/PeerName.js"; import { SelfNameField } from "./components/SelfNameField.js"; @@ -120,6 +125,7 @@ export function App({ const [connections, setConnections] = useState([]); const [discovered, setDiscovered] = useState([]); const [failures, setFailures] = useState([]); + const trust = useCertificateTrust(certificateMemory); const roomMessaging = useRoomMessaging( identity, clock, @@ -161,6 +167,9 @@ export function App({ }, [connections]); // The current domains value, read from a ref rather than closed over directly -- dialExpanded/createExpandableSession are invoked from callbacks createGossipExpansion holds onto for as long as a given session's own gossip directory keeps discovering new peers, well outliving any single render, and must still dial with whatever domains the connect form currently offers, not whichever were selected when that session's own expansion was first wired up. + // Addresses of this console's own connect attempts still waiting on a certificate decision, and which node gossiped each address this console has been offered. + const confirmingRef = useRef(new Set()); + const vouchedBy = useRef(new Map()); const domainsRef = useRef(domains); useEffect(() => { domainsRef.current = domains; @@ -197,14 +206,21 @@ export function App({ /** Asks this console's own user whether to dial a gossiped candidate at all (wire-mesh#187) -- the explicit-confirmation half of the issue's own two named options, since this console holds no persistent device-trust store a gateway_trust-style allow-list could check instead. Resolves once the user clicks Connect or Dismiss in DiscoveredPeersPanel. */ async function confirmExpansion( candidate: Readonly, + via: Readonly, ): Promise { + const key = deviceHex(candidate.device); + // A gossiped address is dialled as the vouching node's own peer, so a connection made through it can name who vouched for what it gossips in turn. + for (const candidateAddress of candidate.addresses) { + vouchedBy.current.set(candidateAddress, key); + } return new Promise((resolve) => { setDiscovered((current) => [ ...current, { - key: deviceHex(candidate.device), + key, device: candidate.device, addresses: candidate.addresses, + via, resolve, }, ]); @@ -219,12 +235,12 @@ export function App({ } /** Builds a session wired to attempt direct connections to its own gossip directory's newly-discovered peers (wire-mesh#187's "discover once connected, expand outward"), confirmed by this console's own user first. Every expansion-dialled session gets the identical wiring in turn, so discovery keeps expanding outward through however many hops of directly-reachable peers actually exist -- not just the one node dialled first, and not just its own immediate peers. */ - function createExpandableSession(): MeshSession { + function createExpandableSession(via: Readonly): MeshSession { const expansionRef: { current: GossipExpansion | null } = { current: null, }; const session = createMeshSession( - createDialTransport(certificateMemory), + createDialTransport(trust.memory), identity, clock, reconnectPolicy, @@ -235,7 +251,7 @@ export function App({ ); expansionRef.current = createGossipExpansion({ selfDeviceId: identity.deviceId, - shouldExpand: confirmExpansion, + shouldExpand: async (candidate) => confirmExpansion(candidate, via), dial: dialExpanded, onExpanded: (_candidate, expandedAddress, expandedSession) => { attachConnection(expandedAddress, expandedSession); @@ -246,10 +262,15 @@ export function App({ } async function dialExpanded(gossipedAddress: string): Promise { - const session = createExpandableSession(); - return session - .connect(toDialAddress(gossipedAddress), domainsRef.current) - .then(() => session); + const dialAddress = toDialAddress(gossipedAddress); + if (!(await trust.confirmAddress(dialAddress))) { + throw new Error(`the certificate of ${gossipedAddress} was not trusted`); + } + const session = createExpandableSession({ + address: gossipedAddress, + device: vouchedBy.current.get(gossipedAddress), + }); + return session.connect(dialAddress, domainsRef.current).then(() => session); } function connectTo(targetAddress: string): void { @@ -259,11 +280,30 @@ export function App({ ) { return; } - const session = createExpandableSession(); - attachConnection(targetAddress, session); - void session.connect(toDialAddress(targetAddress), domains).catch(() => { - // ConnectionPanel's own render of the session's events already surfaces a connect failure via its status line; nothing further to do here beyond letting the entry remain (its own close button still works on a failed session). - }); + const dialAddress = toDialAddress(targetAddress); + // The same address submitted again while its certificate is awaiting a decision is the same attempt, not a second one. + if (confirmingRef.current.has(targetAddress)) { + return; + } + confirmingRef.current.add(targetAddress); + void trust + .confirmAddress(dialAddress) + .then((trusted) => { + if (!trusted) { + return; + } + const session = createExpandableSession({ + address: targetAddress, + device: undefined, + }); + attachConnection(targetAddress, session); + void session.connect(dialAddress, domains).catch(() => { + // ConnectionPanel's own render of the session's events already surfaces a connect failure via its status line; nothing further to do here beyond letting the entry remain (its own close button still works on a failed session). + }); + }) + .finally(() => { + confirmingRef.current.delete(targetAddress); + }); } // discoverLocalNode is a fresh closure every render (or a caller-supplied fake in tests), so the mount-only effect below reads it through a ref (the same pattern attachRef already uses above) rather than listing it as an effect dependency, which would either re-run the probe every render or need a lint suppression. connectTo is a plain function, recreated every render like attachConnection/createExpandableSession above it -- its own ref-update effect below simply runs every render too, which is cheap and still gives the mount effect the latest version by the time it actually fires. @@ -355,6 +395,11 @@ export function App({ + { diff --git a/ts/packages/web-console/src/certificate-trust.ts b/ts/packages/web-console/src/certificate-trust.ts new file mode 100644 index 00000000..50bb4502 --- /dev/null +++ b/ts/packages/web-console/src/certificate-trust.ts @@ -0,0 +1,102 @@ +// The trust-on-first-use decision for a node served over WebTransport, kept pure so the rules can be tested without a session. An address that carries certificate pins is the first time this console is told what a node's certificate is; `certificate-memory.ts` holds what it has been told since. The decision compares the two. + +import { bytesToHex } from "wire-mesh-core/domain/device-id"; +import { + isPinnedAddress, + parsePinnedAddress, +} from "wire-mesh-core/domain/pinned-address"; +import type { CertificateMemory } from "./certificate-memory.js"; + +/** + * What an address's certificate pins say about the node, against what is remembered for it. + * - `first-use`: nothing is remembered, so the user decides whether to trust these certificates. + * - `known`: at least one presented certificate is one already remembered, which is how a rotation looks, since a node always announces the certificate it serves now beside the ones it will serve next. + * - `changed`: something is remembered and none of it is presented, which is what a different node behind the same address, or a replaced certificate, looks like. + */ +export type CertificateAssessment = + | { kind: "first-use"; node: string; presented: readonly string[] } + | { kind: "known"; node: string } + | { + kind: "changed"; + node: string; + remembered: readonly string[]; + presented: readonly string[]; + }; + +export function assessCertificates( + node: string, + presented: readonly string[], + remembered: readonly string[], +): CertificateAssessment { + if (remembered.length === 0) { + return { kind: "first-use", node, presented }; + } + const known = new Set(remembered); + if (presented.some((hash) => known.has(hash))) { + return { kind: "known", node }; + } + return { kind: "changed", node, remembered, presented }; +} + +/** The certificate pins an address presents, with the node (`host:port`) they are for, or undefined for an address that has none to trust: one that is not pinned, or whose pins are malformed. */ +export function presentedCertificates( + address: string, +): { node: string; sha256: Uint8Array[] } | undefined { + if (!isPinnedAddress(address)) { + return undefined; + } + try { + const { url, sha256 } = parsePinnedAddress(address); + return { node: new URL(url).host, sha256: [...sha256] }; + } catch { + // Malformed pins are not something to trust or remember. The dial that follows rejects the address with the parser's own message, which the connection's status line reports. + return undefined; + } +} + +/** A node's announced certificates that share nothing with what was remembered for it. The session was authenticated by a certificate this console had pinned, so the node is reachable, but it has stopped serving anything this console had been told to expect. */ +export interface CertificateChange { + node: string; + remembered: readonly string[]; + presented: readonly string[]; +} + +/** + * A memory that reports every `remember` which replaces a non-empty set with a disjoint one. The write still happens: the announcement arrived on a session this console had already pinned, so it is authentic, but the change is not something to apply silently. + */ +export function observeCertificateChanges( + memory: Readonly, + onChange: (change: Readonly) => void, +): CertificateMemory { + return { + recall: memory.recall, + remember: async (node, hashes) => { + const assessment = assessCertificates( + node, + hashes.map((hash) => bytesToHex(hash)), + (await memory.recall(node)).map((hash) => bytesToHex(hash)), + ); + await memory.remember(node, hashes); + if (assessment.kind === "changed") { + onChange({ + node, + remembered: assessment.remembered, + presented: assessment.presented, + }); + } + }, + }; +} + +const HEX_DIGITS_PER_BYTE = 2; + +/** A certificate hash as browsers and `openssl x509 -fingerprint` show it, uppercase hex pairs separated by colons, so a person can compare it against what the node's operator reads out. */ +export function formatFingerprint(sha256Hex: string): string { + const pairs: string[] = []; + for (let index = 0; index < sha256Hex.length; index += HEX_DIGITS_PER_BYTE) { + pairs.push( + sha256Hex.slice(index, index + HEX_DIGITS_PER_BYTE).toUpperCase(), + ); + } + return pairs.join(":"); +} diff --git a/ts/packages/web-console/src/components/CertificateTrustPanel.tsx b/ts/packages/web-console/src/components/CertificateTrustPanel.tsx new file mode 100644 index 00000000..5ebac0bc --- /dev/null +++ b/ts/packages/web-console/src/components/CertificateTrustPanel.tsx @@ -0,0 +1,125 @@ +// What the user sees at the trust moments: a first-use confirmation naming the node and its certificate, a warning when an address presents a different certificate than the remembered one, and a notice when a connected node announces certificates unrelated to the remembered ones. Decisions are reported upward; nothing here touches storage or a session. + +import { Alert, Button, Code, Group, Stack, Text } from "@mantine/core"; +import { formatFingerprint } from "../certificate-trust.js"; +import type { + NodeCertificateChange, + TrustPrompt, +} from "../hooks/use-certificate-trust.js"; + +function Fingerprints({ + label, + hashes, +}: Readonly<{ label: string; hashes: readonly string[] }>): React.JSX.Element { + return ( +
+ + {label} + + {hashes.map((hash) => ( + + {formatFingerprint(hash)} + + ))} +
+ ); +} + +function PromptAlert({ + prompt, +}: Readonly<{ prompt: TrustPrompt }>): React.JSX.Element { + const { assessment, decide } = prompt; + const changed = assessment.kind === "changed"; + return ( + + + + {changed + ? "This address presents a certificate that shares nothing with the one remembered for this node. The node may have replaced its certificate, or something else may be answering at this address. Only continue if you expected the change." + : "This console has not connected to this node before. Connecting pins the certificate below as the one to expect from it from now on. Compare it with what the node's operator reports before you trust it."} + + {changed && ( + + )} + + + + + + + + ); +} + +export interface CertificateTrustPanelProps { + prompts: readonly TrustPrompt[]; + changes: readonly NodeCertificateChange[]; + onDismissChange: (key: string) => void; +} + +export function CertificateTrustPanel({ + prompts, + changes, + onDismissChange, +}: Readonly): React.JSX.Element | null { + if (prompts.length === 0 && changes.length === 0) { + return null; + } + return ( + + {prompts.map((prompt) => ( + + ))} + {changes.map((change) => ( + { + onDismissChange(change.key); + }} + > + + + The node is still reachable through the certificate you pinned, + but it now announces certificates that share nothing with the ones + remembered for it, and the announced set is what this console will + pin from now on. + + + + + + ))} + + ); +} diff --git a/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx b/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx index 9be97f24..5c68069c 100644 --- a/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx +++ b/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx @@ -5,10 +5,19 @@ import type { DeviceId } from "wire-mesh-core/generated/protocol"; import { deviceHex } from "./ConnectionPanel.js"; import { PeerLabel } from "./PeerLabel.js"; +/** Where a discovered peer was learned of: the connection whose directory gossiped it, and, when that connection was itself reached by gossip, the device that vouched for it. */ +export interface DiscoveredVia { + /** The address the gossiping connection was dialled at. */ + address: string; + /** Hex device-id of the node on that connection, when it is known. */ + device: string | undefined; +} + export interface DiscoveredPeerRow { key: string; device: DeviceId; addresses: readonly string[]; + via: DiscoveredVia; } export interface DiscoveredPeersPanelProps { @@ -30,11 +39,17 @@ export function DiscoveredPeersPanel({ Discovered peers + + Each node below was listed in the directory of a node you are connected + to, which is the only thing vouching for it: the address it lists may + not be a wire-mesh node at all. Nothing is dialled until you connect. + device addresses + gossiped by @@ -45,6 +60,14 @@ export function DiscoveredPeersPanel({ {peer.addresses.join(", ")} + + {peer.via.device !== undefined && ( + + )} + + over {peer.via.address} + +
+ + + direction + frame + + + + {frameLog.map((entry, index) => ( + + {entry.direction} + {describeRawFrame(entry.frame)} + + ))} + +
+ + ) : ( + <> + + Activity + + {activity.length === 0 ? ( + + Nothing has happened yet. + + ) : ( + + + + {[...activity].reverse().map((entry) => ( + + + {new Date(entry.at).toLocaleTimeString()} + + + {entry.summary} + {entry.peer !== undefined && ( + <> + {" "} + + + )} + + + ))} + +
+
+ )} + + )} + + ); +} diff --git a/ts/packages/web-console/src/components/CertificateTrustPanel.tsx b/ts/packages/web-console/src/components/CertificateTrustPanel.tsx index 5ebac0bc..fcd0f9f4 100644 --- a/ts/packages/web-console/src/components/CertificateTrustPanel.tsx +++ b/ts/packages/web-console/src/components/CertificateTrustPanel.tsx @@ -89,7 +89,8 @@ export function CertificateTrustPanel({ changes, onDismissChange, }: Readonly): React.JSX.Element | null { - if (prompts.length === 0 && changes.length === 0) { + const undismissed = changes.filter((change) => !change.dismissed); + if (prompts.length === 0 && undismissed.length === 0) { return null; } return ( @@ -97,7 +98,7 @@ export function CertificateTrustPanel({ {prompts.map((prompt) => ( ))} - {changes.map((change) => ( + {undismissed.map((change) => ( ; + /** When the connection's status last changed, which is when a reconnect's backoff started. */ + statusSince: number | undefined; + now: number; + policy: Readonly; + health: Readonly; +} + +function ReconnectStatus({ + state, + statusSince, + now, + policy, +}: Readonly< + Pick & { + state: Extract; + } +>): React.JSX.Element { + const retryAt = + statusSince === undefined + ? undefined + : statusSince + policy.delayMs(state.attempt); + const due = retryAt === undefined ? 0 : retryAt - now; + return ( + + {due > 0 ? `Retrying in ${formatRemaining(due)}` : "Retrying now"}{" "} + (attempt {state.attempt} of {policy.maxAttempts}). Last failure:{" "} + {state.reason} + + ); +} + +export function ConnectionHealth({ + state, + statusSince, + now, + policy, + health, +}: Readonly): React.JSX.Element | null { + if (state.status === "reconnecting") { + return ( + + ); + } + if (state.status !== "connected") { + return null; + } + return ( + + + {health.unresponsive && ( + + This node does not answer pings, so round trips are not measured + automatically. + + )} + + ); +} diff --git a/ts/packages/web-console/src/components/ConnectionPanel.tsx b/ts/packages/web-console/src/components/ConnectionPanel.tsx index 9e565ede..3eeb0870 100644 --- a/ts/packages/web-console/src/components/ConnectionPanel.tsx +++ b/ts/packages/web-console/src/components/ConnectionPanel.tsx @@ -1,12 +1,14 @@ // One open relay/hub connection's own UI: status line, ping/disconnect controls, peer directory, and frame log. All behaviour lives in wire-mesh-core's own mesh-session domain module; this component only renders whatever useMeshSessionEvents last reported and forwards clicks back onto the session. -import { Alert, Button, Card, Group, Table, Text } from "@mantine/core"; +import { Alert, Button, Card, Group, Table, Text, Tooltip } from "@mantine/core"; import type { MeshSession, + ReconnectPolicy, SessionEvent, } from "wire-mesh-core/domain/mesh-session"; +import type { Clock } from "wire-mesh-core/ports/clock"; import type { DeviceId } from "wire-mesh-core/generated/protocol"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useMeshSessionEvents } from "../hooks/use-mesh-session-events.js"; import { browserPermissions, @@ -15,9 +17,19 @@ import { } from "../local-network.js"; import { usePeerNames } from "../hooks/use-peer-names.js"; import { selfNameExtension } from "../peer-names.js"; +import { useConnectionHealth } from "../hooks/use-connection-health.js"; +import { useNow } from "../hooks/use-now.js"; +import { certificateActivity } from "../activity.js"; +import { formatAgo } from "../format-duration.js"; +import { presentedCertificates } from "../certificate-trust.js"; +import { toDialAddress } from "../dial-address.js"; +import type { NodeCertificateChange } from "../hooks/use-certificate-trust.js"; +import { ActivityLog } from "./ActivityLog.js"; +import { ConnectionHealth } from "./ConnectionHealth.js"; import { PeerLabel } from "./PeerLabel.js"; const HEX_RADIX = 16; +const MS_PER_SECOND = 1000; export function deviceHex(device: Uint8Array): string { let hex = ""; @@ -61,15 +73,14 @@ function describeStatus(event: Readonly): string { return ""; } -function describeFrame(frame: unknown): string { - return JSON.stringify(frame, (_key: string, value: unknown): unknown => - value instanceof Uint8Array ? `<${String(value.byteLength)} bytes>` : value, - ); -} - export interface ConnectionPanelProps { address: string; session: Readonly; + clock: Readonly; + /** The backoff the session redials with, so the panel can say when the next attempt is due. */ + reconnectPolicy: Readonly; + /** Every certificate change seen for any node; the panel shows those for its own node in its activity. */ + certificateChanges: readonly NodeCertificateChange[]; onClose: () => void; onMessagePeer: (device: DeviceId) => void; /** The host of the page this panel is shown on, which decides whether reaching a local address needs the browser's permission. Defaults to the real page's. */ @@ -78,15 +89,25 @@ export interface ConnectionPanelProps { permissions?: PermissionQuerier | undefined; } +/** How often elapsed times and the reconnect countdown refresh. */ +const CLOCK_TICK_MS = 1000; + export function ConnectionPanel({ address, session, + clock, + reconnectPolicy, + certificateChanges, onClose, onMessagePeer, pageHost = window.location.hostname, permissions = browserPermissions(), }: Readonly): React.JSX.Element { - const event = useMeshSessionEvents(session); + const { + event, + activity: sessionActivity, + statusSince, + } = useMeshSessionEvents(session, clock); const failing = event?.state.status === "reconnecting" || event?.state.status === "closed"; // What the browser's answer was for one address; ignored once the connection is working again or the address is another. @@ -145,19 +166,31 @@ export function ConnectionPanel({ const status = event === undefined ? "idle" : describeStatus(event); const directory = event?.directory ?? []; const frameLog = event?.frameLog ?? []; + const health = useConnectionHealth(session, connected); + const reconnecting = event?.state.status === "reconnecting"; + const now = useNow( + clock, + reconnecting || directory.length > 0, + CLOCK_TICK_MS, + ); + const node = presentedCertificates(toDialAddress(address))?.node; + const activity = useMemo( + () => + [ + ...sessionActivity, + ...certificateActivity( + certificateChanges.filter((change) => change.node === node), + ), + ].sort((a, b) => a.at - b.at), + [sessionActivity, certificateChanges, node], + ); return ( {address} - + )} + + {intro.visible && }
@@ -428,6 +444,10 @@ export function App({ {failure.reason}. Messages go through the hub instead. ))} + ) : ( - +
device @@ -241,11 +242,13 @@ export function ConnectionPanel({ {directory.map((entry) => ( - + - {entry.advert.addresses.join(", ")} - + + {entry.advert.addresses.join(", ")} + + {formatAgo( @@ -254,7 +257,7 @@ export function ConnectionPanel({ - +
+
device @@ -56,11 +57,13 @@ export function DiscoveredPeersPanel({ {peers.map((peer) => ( - + - {peer.addresses.join(", ")} - + + {peer.addresses.join(", ")} + + {peer.via.device !== undefined && ( )} @@ -68,7 +71,7 @@ export function DiscoveredPeersPanel({ over {peer.via.address} - + + + ); +} diff --git a/ts/packages/web-console/src/components/SearchPanel.tsx b/ts/packages/web-console/src/components/SearchPanel.tsx new file mode 100644 index 00000000..c1656ea6 --- /dev/null +++ b/ts/packages/web-console/src/components/SearchPanel.tsx @@ -0,0 +1,87 @@ +// Search box and results over every conversation's messages and notices. A result names its conversation, says whether it is a message or a notice, highlights what matched, and opens that conversation when clicked. The matching itself is search.ts; this only renders it. + +import { useState } from "react"; +import { + Badge, + Group, + Highlight, + NavLink, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { participantLabel } from "../conversations.js"; +import type { ConversationView } from "../conversations.js"; +import { usePeerNames } from "../hooks/use-peer-names.js"; +import { queryTerms, searchConversations } from "../search.js"; + +export interface SearchPanelProps { + conversations: readonly ConversationView[]; + onSelect: (roomPath: string) => void; +} + +export function SearchPanel({ + conversations, + onSelect, +}: Readonly): React.JSX.Element | null { + const names = usePeerNames(); + const [query, setQuery] = useState(""); + if (conversations.length === 0) { + return null; + } + const results = searchConversations(conversations, query); + const searching = queryTerms(query).length > 0; + const conversationOf = new Map( + conversations.map((conversation) => [conversation.roomPath, conversation]), + ); + return ( + + { + setQuery(event.currentTarget.value); + }} + /> + {searching && results.length === 0 && ( + + Nothing matches. + + )} + {results.map((result) => { + const conversation = conversationOf.get(result.roomPath); + return ( + + {result.text} + + } + description={ + + + {result.kind} + + + {conversation === undefined + ? result.roomPath + : participantLabel( + conversation, + (hex) => names.labelOf(hex).primary, + )} + {" · "} + {new Date(result.at).toLocaleString()} + + + } + onClick={() => { + onSelect(result.roomPath); + }} + /> + ); + })} + + ); +} diff --git a/ts/packages/web-console/src/hooks/use-intro.ts b/ts/packages/web-console/src/hooks/use-intro.ts new file mode 100644 index 00000000..54546e62 --- /dev/null +++ b/ts/packages/web-console/src/hooks/use-intro.ts @@ -0,0 +1,42 @@ +// Whether the first-run intro is on screen. It opens on a device that has never dismissed it, stays closed until the stored answer has loaded so a returning user never sees it flash, and can be reopened. + +import { useCallback, useEffect, useState } from "react"; +import type { PreferencesStore } from "../preferences-store.js"; + +export interface Intro { + /** True once the stored answer has loaded and the intro is wanted. */ + visible: boolean; + /** True once the stored answer has loaded, so the control that reopens the intro is not offered before then. */ + ready: boolean; + dismiss: () => void; + show: () => void; +} + +export function useIntro(store: Readonly): Intro { + const [dismissed, setDismissed] = useState(undefined); + + useEffect(() => { + void store.introDismissed().then(setDismissed); + }, [store]); + + const remember = useCallback( + (value: boolean): void => { + setDismissed(value); + void store.setIntroDismissed(value); + }, + [store], + ); + const dismiss = useCallback(() => { + remember(true); + }, [remember]); + const show = useCallback(() => { + remember(false); + }, [remember]); + + return { + visible: dismissed === false, + ready: dismissed !== undefined, + dismiss, + show, + }; +} diff --git a/ts/packages/web-console/src/main.tsx b/ts/packages/web-console/src/main.tsx index 88626e86..4f2b2e4c 100644 --- a/ts/packages/web-console/src/main.tsx +++ b/ts/packages/web-console/src/main.tsx @@ -7,6 +7,7 @@ import { MantineProvider } from "@mantine/core"; import { createIndexedDbStorage } from "./adapters/indexeddb-storage.js"; import { createPersistedWebCryptoIdentity } from "./adapters/web-crypto-identity.js"; import { createCertificateMemory } from "./certificate-memory.js"; +import { createPreferencesStore } from "./preferences-store.js"; import { createNameStore } from "./name-store.js"; import { createMessageStore } from "./message-store.js"; import { App } from "./App.js"; @@ -23,6 +24,7 @@ const certificateMemory = createCertificateMemory( await createIndexedDbStorage(), ); const nameStore = createNameStore(await createIndexedDbStorage()); +const preferences = createPreferencesStore(await createIndexedDbStorage()); const address = defaultHubAddress(import.meta.env.DEV, window.location); const container = document.getElementById("root"); @@ -40,6 +42,7 @@ createRoot(container).render( roomStorage={roomStorage} certificateMemory={certificateMemory} nameStore={nameStore} + preferences={preferences} defaultAddress={address} /> diff --git a/ts/packages/web-console/src/preferences-store.ts b/ts/packages/web-console/src/preferences-store.ts new file mode 100644 index 00000000..f1ef29d1 --- /dev/null +++ b/ts/packages/web-console/src/preferences-store.ts @@ -0,0 +1,29 @@ +// Persistence for the console's own interface preferences over the portable KeyValueStorage port: today, whether the first-run intro has been dismissed. + +import type { KeyValueStorage } from "wire-mesh-core/ports/storage"; + +const INTRO_DISMISSED_KEY = "preferences/intro-dismissed"; +const FLAG_SET = new Uint8Array([1]); + +export interface PreferencesStore { + introDismissed: () => Promise; + /** Records that the intro was dismissed, or (with false) that it should be shown again. */ + setIntroDismissed: (dismissed: boolean) => Promise; +} + +export function createPreferencesStore( + storage: Readonly, +): PreferencesStore { + return { + async introDismissed() { + return (await storage.get(INTRO_DISMISSED_KEY)) !== undefined; + }, + async setIntroDismissed(dismissed) { + if (dismissed) { + await storage.set(INTRO_DISMISSED_KEY, new Uint8Array(FLAG_SET)); + } else { + await storage.delete(INTRO_DISMISSED_KEY); + } + }, + }; +} diff --git a/ts/packages/web-console/src/search.ts b/ts/packages/web-console/src/search.ts new file mode 100644 index 00000000..a46fb8c6 --- /dev/null +++ b/ts/packages/web-console/src/search.ts @@ -0,0 +1,78 @@ +// Search across every conversation's messages and notices. The conversation state already holds the message history restored from the IndexedDB message store and every message that has arrived since, plus the notices each conversation's board has read, so searching it covers stored and live content alike with no second scan of storage. Pure, so matching and ordering can be tested without a UI. + +import type { ConversationView } from "./conversations.js"; + +/** The most results returned. The list is rendered whole, so an unbounded one would make a one-letter query lay out the entire history. */ +export const MAX_SEARCH_RESULTS = 50; + +export interface SearchResult { + /** Unique across results: the room path, the kind and the position within that conversation's list. */ + key: string; + roomPath: string; + kind: "message" | "notice"; + /** The text that matched. */ + text: string; + /** When it was sent or posted, in milliseconds since the epoch. */ + at: number; + /** For a message, whether this console sent it. A notice has no direction. */ + direction: "sent" | "received" | undefined; +} + +/** The lowercase, non-empty whitespace-separated terms of a query. */ +export function queryTerms(query: string): string[] { + return query + .toLowerCase() + .split(/\s+/) + .filter((term) => term !== ""); +} + +function matches(text: string, terms: readonly string[]): boolean { + const haystack = text.toLowerCase(); + return terms.every((term) => haystack.includes(term)); +} + +/** + * The messages and readable notices containing every term of `query` (case-insensitively, in any order), newest first, at most MAX_SEARCH_RESULTS. A notice this console cannot decrypt has no text and never matches. A blank query matches nothing. + */ +export function searchConversations( + conversations: readonly ConversationView[], + query: string, +): SearchResult[] { + const terms = queryTerms(query); + if (terms.length === 0) { + return []; + } + const decoder = new TextDecoder(); + const results: SearchResult[] = []; + for (const conversation of conversations) { + conversation.messages.forEach((message, index) => { + if (matches(message.text, terms)) { + results.push({ + key: `${conversation.roomPath}/message/${String(index)}`, + roomPath: conversation.roomPath, + kind: "message", + text: message.text, + at: message.sentAt, + direction: message.direction, + }); + } + }); + conversation.notices.forEach((notice, index) => { + if (notice.plaintext === undefined || notice.claims === undefined) { + return; + } + const text = decoder.decode(notice.plaintext); + if (matches(text, terms)) { + results.push({ + key: `${conversation.roomPath}/notice/${String(index)}`, + roomPath: conversation.roomPath, + kind: "notice", + text, + at: notice.claims["posted-at"], + direction: undefined, + }); + } + }); + } + return results.sort((a, b) => b.at - a.at).slice(0, MAX_SEARCH_RESULTS); +} diff --git a/ts/packages/web-console/test/App.unit.test.tsx b/ts/packages/web-console/test/App.unit.test.tsx index aa7e9142..d7f65621 100644 --- a/ts/packages/web-console/test/App.unit.test.tsx +++ b/ts/packages/web-console/test/App.unit.test.tsx @@ -32,6 +32,7 @@ import { formatPinnedAddress } from "wire-mesh-core/domain/pinned-address"; import { signPeerAdvert } from "wire-mesh-core/domain/peer-advert"; import { createWebCryptoIdentity } from "../src/adapters/web-crypto-identity.js"; import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage"; +import { createPreferencesStore } from "../src/preferences-store.js"; import { createNameStore } from "../src/name-store.js"; import { selfAssertedName, @@ -78,6 +79,13 @@ class TrackedFakeWebSocket extends FakeWebSocket { } } +/** Storage holding the answer of a device whose user has already dismissed the intro. */ +function dismissedPreferencesStorage(): KeyValueStorage { + const storage = createMemoryStorage(); + void createPreferencesStore(storage).setIntroDismissed(true); + return storage; +} + function renderApp( options: Readonly<{ discoverLocalNode?: () => Promise; @@ -85,6 +93,8 @@ function renderApp( messageStore?: MessageStore; nameStorage?: KeyValueStorage; certificateMemory?: CertificateMemory; + /** The storage the intro preference is kept in. Defaults to one where the intro was already dismissed, so tests not about it see the console as a returning user does. */ + preferencesStorage?: KeyValueStorage; }> = {}, ): ReturnType { const { @@ -93,6 +103,7 @@ function renderApp( messageStore = fakeMessageStore(), nameStorage = createMemoryStorage(), certificateMemory = createCertificateMemory(createMemoryStorage()), + preferencesStorage = dismissedPreferencesStorage(), } = options; return render( @@ -103,6 +114,7 @@ function renderApp( roomStorage={createMemoryStorage()} certificateMemory={certificateMemory} nameStore={createNameStore(nameStorage)} + preferences={createPreferencesStore(preferencesStorage)} {...(discoverLocalNode === undefined ? {} : { discoverLocalNode })} {...(defaultAddress === undefined ? {} : { defaultAddress })} /> @@ -691,4 +703,78 @@ describe("App", () => { within(panel).getByText("over ws://hub.example:8787"), ).toBeInTheDocument(); }); + + describe("first-run intro", () => { + it("shows on a device that has never dismissed it, and explains why discovered peers wait", async () => { + renderApp({ preferencesStorage: createMemoryStorage() }); + + const intro = await screen.findByTestId("onboarding-intro"); + expect( + within(intro).getByText(/Discovered peers wait for you/), + ).toBeInTheDocument(); + }); + + it("is dismissed by Got it, stays dismissed on the next visit, and comes back from the header", async () => { + const preferencesStorage = createMemoryStorage(); + const first = renderApp({ preferencesStorage }); + fireEvent.click(await screen.findByRole("button", { name: "Got it" })); + + expect(screen.queryByTestId("onboarding-intro")).toBeNull(); + first.unmount(); + + renderApp({ preferencesStorage }); + fireEvent.click( + await screen.findByRole("button", { name: "How this works" }), + ); + + expect(screen.getByTestId("onboarding-intro")).toBeInTheDocument(); + }); + + it("does not show to a returning user", async () => { + renderApp(); + + await screen.findByRole("button", { name: "How this works" }); + expect(screen.queryByTestId("onboarding-intro")).toBeNull(); + }); + }); + + it("finds a stored message by search and opens its conversation", async () => { + const peerHex = "2".repeat(DEVICE_ID_HEX_LENGTH); + const store = fakeMessageStore(); + await store.append( + dmRoomPath(deviceIdToHex(appIdentity.deviceId), peerHex), + { + direction: "received", + text: "meet by the harbour", + messageId: new Uint8Array([1]), + sentAt: 1000, + }, + ); + renderApp({ messageStore: store }); + + fireEvent.change( + await screen.findByLabelText("Search conversations and notices"), + { target: { value: "harbour" } }, + ); + + const panel = await screen.findByTestId("search-panel"); + expect( + within(panel).getByText("harbour", { selector: "mark" }), + ).toBeInTheDocument(); + }); + + it("labels every directory cell with its column so the table can stack on a phone", async () => { + renderApp(); + const rootSocket = await connectRoot(); + rootSocket.emitMessage(arrayBuffer(messageFromFrame(gossipedFrame))); + + const panel = await screen.findByTestId("discovered-peers"); + const cells = [...panel.querySelectorAll("tbody td")]; + expect(cells.map((cell) => cell.getAttribute("data-label"))).toEqual([ + "device", + "addresses", + "gossiped by", + "actions", + ]); + }); }); diff --git a/ts/packages/web-console/test/SearchPanel.unit.test.tsx b/ts/packages/web-console/test/SearchPanel.unit.test.tsx new file mode 100644 index 00000000..fd713def --- /dev/null +++ b/ts/packages/web-console/test/SearchPanel.unit.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { SearchPanel } from "../src/components/SearchPanel.js"; +import type { ConversationView } from "../src/conversations.js"; +import { WithNames, memoryNameStore } from "./names-harness.js"; +import { stubMantineJsdomGlobals } from "./jsdom-mantine-polyfills.js"; + +const DEVICE_ID_HEX_LENGTH = 64; +const PEER = "2".repeat(DEVICE_ID_HEX_LENGTH); + +function conversation(text: string): ConversationView { + return { + roomPath: `${"1".repeat(DEVICE_ID_HEX_LENGTH)}+${PEER}`, + participants: [PEER], + status: "connected", + via: "direct", + messages: [ + { + direction: "received", + text, + messageId: new Uint8Array([1]), + sentAt: 1, + }, + ], + notices: [], + outgoing: [], + pendingJoinRequest: undefined, + unread: 0, + }; +} + +function renderSearch( + conversations: readonly ConversationView[], + onSelect: (roomPath: string) => void = () => undefined, + store = memoryNameStore(), +): void { + render( + + + , + ); +} + +function search(query: string): void { + fireEvent.change(screen.getByLabelText("Search conversations and notices"), { + target: { value: query }, + }); +} + +describe("SearchPanel", () => { + beforeEach(() => { + stubMantineJsdomGlobals(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("renders nothing while there are no conversations to search", () => { + renderSearch([]); + + expect(screen.queryByTestId("search-panel")).not.toBeInTheDocument(); + }); + + it("lists no results for a blank query", () => { + renderSearch([conversation("lunch at the harbour")]); + + expect(screen.queryByText(/lunch/)).toBeNull(); + expect(screen.queryByText("Nothing matches.")).toBeNull(); + }); + + it("shows a matching message with its kind and the peer's petname, and opens its conversation on click", async () => { + const store = memoryNameStore(); + await store.setPetname(PEER, "Ada"); + const onSelect = vi.fn<(roomPath: string) => void>(); + const view = conversation("lunch at the harbour"); + renderSearch([view], onSelect, store); + + search("harbour"); + + const result = await screen.findByText("harbour", { selector: "mark" }); + expect(screen.getByText("message")).toBeInTheDocument(); + expect(screen.getByText(/^Ada/)).toBeInTheDocument(); + + fireEvent.click(result); + + expect(onSelect).toHaveBeenCalledWith(view.roomPath); + }); + + it("says when nothing matches", () => { + renderSearch([conversation("lunch at the harbour")]); + + search("zebra"); + + expect(screen.getByText("Nothing matches.")).toBeInTheDocument(); + }); +}); diff --git a/ts/packages/web-console/test/e2e/onboarding-mobile.e2e.spec.ts b/ts/packages/web-console/test/e2e/onboarding-mobile.e2e.spec.ts new file mode 100644 index 00000000..ecc1499a --- /dev/null +++ b/ts/packages/web-console/test/e2e/onboarding-mobile.e2e.spec.ts @@ -0,0 +1,74 @@ +// A real-browser check of the first-run intro and the phone layout, against a real relay and two independent console instances: a device's intro appears once and stays dismissed after a reload, and on a phone-width viewport the peer directory, which has a real row because a second console is connected, stacks into a card that fits the screen, where the same table on a desktop-width viewport keeps its columns. + +import { type Page, chromium, expect, test } from "@playwright/test"; +import { RELAY_ADDRESS, VITE_PORT } from "../../playwright.config.js"; + +const PHONE_VIEWPORT = { width: 360, height: 640 }; +const DIRECTORY_TIMEOUT_MS = 15_000; +const TEST_TIMEOUT_MS = 60_000; + +/** Whether the page fits its viewport horizontally: a table too wide for a phone widens the document past it. */ +async function fitsViewportWidth(page: Readonly): Promise { + return page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ); +} + +/** How the browser lays out a directory cell: a table cell on a wide screen, a block once the row has stacked into a card. */ +async function directoryCellDisplay(page: Readonly): Promise { + return page + .locator('td[data-label="device"]') + .first() + .evaluate((cell) => getComputedStyle(cell).display); +} + +test("the intro shows once per device, and the directory stacks into cards on a phone screen", async () => { + test.setTimeout(TEST_TIMEOUT_MS); + const appUrl = `http://localhost:${String(VITE_PORT)}/`; + const phoneBrowser = await chromium.launch(); + const peerBrowser = await chromium.launch(); + try { + const phone = await ( + await phoneBrowser.newContext({ viewport: PHONE_VIEWPORT }) + ).newPage(); + phone.on("pageerror", (error) => { + throw error; + }); + const peer = await (await peerBrowser.newContext()).newPage(); + peer.on("pageerror", (error) => { + throw error; + }); + + await phone.goto(appUrl); + await expect(phone.getByTestId("onboarding-intro")).toBeVisible(); + await phone.getByRole("button", { name: "Got it" }).click(); + await expect(phone.getByTestId("onboarding-intro")).toBeHidden(); + await phone.reload(); + await expect( + phone.getByRole("button", { name: "How this works" }), + ).toBeVisible(); + await expect(phone.getByTestId("onboarding-intro")).toBeHidden(); + + await peer.goto(appUrl); + for (const page of [phone, peer]) { + await page.getByLabel("Node").fill(RELAY_ADDRESS); + await page.getByRole("button", { name: "Connect" }).click(); + } + + const messageButton = phone.getByRole("button", { name: "Message" }); + await expect(messageButton).toBeVisible({ timeout: DIRECTORY_TIMEOUT_MS }); + expect(await fitsViewportWidth(phone)).toBe(true); + expect(await directoryCellDisplay(phone)).toBe("block"); + await expect(peer.getByRole("button", { name: "Message" })).toBeVisible({ + timeout: DIRECTORY_TIMEOUT_MS, + }); + expect(await directoryCellDisplay(peer)).toBe("table-cell"); + const box = await messageButton.boundingBox(); + expect(box).not.toBeNull(); + expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual( + PHONE_VIEWPORT.width, + ); + } finally { + await Promise.all([phoneBrowser.close(), peerBrowser.close()]); + } +}); diff --git a/ts/packages/web-console/test/preferences-store.unit.test.ts b/ts/packages/web-console/test/preferences-store.unit.test.ts new file mode 100644 index 00000000..a9aa734d --- /dev/null +++ b/ts/packages/web-console/test/preferences-store.unit.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage"; +import { createPreferencesStore } from "../src/preferences-store.js"; + +describe("preferences store", () => { + it("has not dismissed the intro on a fresh device", async () => { + expect( + await createPreferencesStore(createMemoryStorage()).introDismissed(), + ).toBe(false); + }); + + it("remembers a dismissal across store instances over the same storage, and forgets it when reset", async () => { + const storage = createMemoryStorage(); + await createPreferencesStore(storage).setIntroDismissed(true); + + expect(await createPreferencesStore(storage).introDismissed()).toBe(true); + + await createPreferencesStore(storage).setIntroDismissed(false); + + expect(await createPreferencesStore(storage).introDismissed()).toBe(false); + }); +}); diff --git a/ts/packages/web-console/test/search.unit.test.ts b/ts/packages/web-console/test/search.unit.test.ts new file mode 100644 index 00000000..41a0c176 --- /dev/null +++ b/ts/packages/web-console/test/search.unit.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import type { ConversationView } from "../src/conversations.js"; +import type { StoredMessage } from "../src/message-store.js"; +import { deviceIdFromFillHex } from "./hex.js"; +import { syntheticAdvertProof } from "./synthetic-advert.js"; +import { + MAX_SEARCH_RESULTS, + queryTerms, + searchConversations, +} from "../src/search.js"; + +const DEVICE_ID_HEX_LENGTH = 64; +const OLDEST = 10; +const MIDDLE = 20; +const NEWEST = 30; +const THIRD_MESSAGE = 3; + +function message( + text: string, + sentAt: number, + direction: StoredMessage["direction"] = "received", +): StoredMessage { + return { direction, text, messageId: new Uint8Array([sentAt]), sentAt }; +} + +function conversation( + digit: string, + overrides: Partial = {}, +): ConversationView { + const peer = digit.repeat(DEVICE_ID_HEX_LENGTH); + return { + roomPath: `${"1".repeat(DEVICE_ID_HEX_LENGTH)}+${peer}`, + participants: [peer], + status: "connected", + via: "direct", + messages: [], + notices: [], + outgoing: [], + pendingJoinRequest: undefined, + unread: 0, + ...overrides, + }; +} + +/** A verified notice entry whose claims are well-formed but carry filler for everything the search does not read. */ +function notice( + text: string, + postedAt: number, +): ConversationView["notices"][number] { + const content = new TextEncoder().encode(text); + return { + verified: true, + contentType: "text/plain", + plaintext: content, + claims: { + room: conversation("2").roomPath, + poster: deviceIdFromFillHex("33"), + "poster-key": syntheticAdvertProof()["identity-key"], + token: [new Uint8Array(), {}, null, new Uint8Array()], + "notice-id": new Uint8Array(), + "posted-at": postedAt, + "content-type": "text/plain", + content, + }, + }; +} + +describe("queryTerms", () => { + it("lowercases and splits on whitespace, dropping blanks", () => { + expect(queryTerms(" Hello WORLD ")).toEqual(["hello", "world"]); + expect(queryTerms(" ")).toEqual([]); + }); +}); + +describe("searchConversations", () => { + it("finds messages containing every term, in any case and order", () => { + const results = searchConversations( + [ + conversation("2", { + messages: [ + message("Lunch at the harbour", 1), + message("harbour closed", 2), + message("nothing here", THIRD_MESSAGE), + ], + }), + ], + "HARBOUR lunch", + ); + + expect(results.map((result) => result.text)).toEqual([ + "Lunch at the harbour", + ]); + }); + + it("searches messages and readable notices together, newest first, and says which is which", () => { + const first = conversation("2", { + messages: [message("deploy notes", OLDEST)], + notices: [notice("deploy window agreed", NEWEST)], + }); + const second = conversation("3", { + messages: [message("deploy done", MIDDLE)], + }); + + const results = searchConversations([first, second], "deploy"); + + expect( + results.map((result) => [result.kind, result.text, result.roomPath]), + ).toEqual([ + ["notice", "deploy window agreed", first.roomPath], + ["message", "deploy done", second.roomPath], + ["message", "deploy notes", first.roomPath], + ]); + }); + + it("reports whether a matching message was sent or received", () => { + const [sent] = searchConversations( + [conversation("2", { messages: [message("ping", 1, "sent")] })], + "ping", + ); + + expect(sent?.direction).toBe("sent"); + }); + + it("never matches a notice this console cannot decrypt, nor a blank query", () => { + const unreadable: ConversationView["notices"][number] = { verified: true }; + const conversations = [ + conversation("2", { + messages: [message("hello", 1)], + notices: [unreadable], + }), + ]; + + expect(searchConversations(conversations, "hello")).toHaveLength(1); + expect(searchConversations(conversations, " ")).toEqual([]); + }); + + it("gives each result a distinct key and caps how many it returns", () => { + const messages = Array.from( + { length: MAX_SEARCH_RESULTS + 1 }, + (_, index) => message("match", index), + ); + + const results = searchConversations( + [conversation("2", { messages })], + "match", + ); + + expect(results).toHaveLength(MAX_SEARCH_RESULTS); + expect(new Set(results.map((result) => result.key)).size).toBe( + MAX_SEARCH_RESULTS, + ); + }); +}); From 26190262ab16346d93132c3ab3e29ad2fac8b6f4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Sep 2026 20:40:21 +0100 Subject: [PATCH 16/47] docs(web-console): describe naming, trust, activity, first run and search --- ts/packages/web-console/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ts/packages/web-console/README.md b/ts/packages/web-console/README.md index 7936bc03..f8a10233 100644 --- a/ts/packages/web-console/README.md +++ b/ts/packages/web-console/README.md @@ -8,8 +8,11 @@ The browser client for wire-mesh: a client of *any* node, not just the hub. It c - **Identity** — the console holds a persisted Web Crypto (P-256) identity (`src/adapters/web-crypto-identity.ts`'s `createPersistedWebCryptoIdentity`, backed by `src/adapters/indexeddb-storage.ts`), stable across reloads and shared across every connection the console makes. - **Capability tokens** — `MeshSession.setToken` accepts a capability token to present on subsequent `manage-request`s (`sendManageRequest`), gating anything the connected node or a relayed peer requires authorization for. - **Reconnect** — a dropped connection retries with backoff (opt-in `ReconnectPolicy`; `main.ts` wires a default exponential backoff, capped, for every session it creates), surfaced as an explicit `"reconnecting"` status rather than silently going dark. -- **Peer directory** — every `gossip` frame the node sends is folded into a directory table (device, addresses, snapshot time), latest advert per device winning. -- **Frame log** — a live, ordered feed of every frame that crossed the connection in either direction, plus a *Send ping* button. +- **Peer directory** — every `gossip` frame the node sends is folded into a directory table (device, addresses, last seen), latest advert per device winning. On a phone-width screen each row stacks into a labelled card (`stackedTable` in `src/App.css.ts`). +- **Naming** — a peer is named wherever it appears by one convention (`src/peer-names.ts`): a petname this viewer gave the device, else the display name the device signed about itself, else a short id with a button that copies the full device-id. Petnames live in this console's own storage (`src/name-store.ts`) and are never gossiped. A self display name travels in the `agent/self` extension of a verified peer-advert, the key agent-comms publishes its agent name under and the one the public hub's advert policy carries, and this console publishes its own the same way. +- **Trust** — an address that carries certificate pins is assessed against `src/certificate-memory.ts` before it is dialled (`src/certificate-trust.ts`): nothing remembered asks for a first-use confirmation showing the certificate fingerprint, a remembered set sharing nothing with the presented one raises a change warning that must be accepted, and an overlapping set (a rotation) connects silently. A connected node that announces certificates unlike the remembered ones is reported rather than overwritten silently. Each discovered-peer row names the connection whose directory gossiped it. +- **Activity and health** — the connection panel narrates the session as sentences (`src/activity.ts`) with the raw frame log behind a toggle. Round-trip time is probed without a button press and drawn as a sparkline, a dropped connection shows when the next reconnect attempt is due, and directory rows show when a peer was last seen. +- **First run and search** — a device that has not dismissed it sees a short walkthrough (connect, who is here, why discovered peers wait for an explicit connect, what works offline), reopenable from the header. Search covers the messages and readable notices of every conversation (`src/search.ts`) and opens the conversation a result belongs to. ## How it maps onto core's ports From bbfe0c3d38d2699256ec8159c6ffc79e2343ede1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Sep 2026 20:59:57 +0100 Subject: [PATCH 17/47] feat(web-console): back up and restore the device identity key The backup is a JSON file holding the private key as a standard P-256 JWK with the public key and device-id it derives, checked on restore so a file whose key, public key and device-id disagree is refused. Restoring replaces the stored identity, which loads the next time the console does. --- .../src/adapters/identity-backup.ts | 160 ++++++++++++++++++ .../src/adapters/web-crypto-identity.ts | 7 +- .../test/identity-backup.unit.test.ts | 119 +++++++++++++ 3 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 ts/packages/web-console/src/adapters/identity-backup.ts create mode 100644 ts/packages/web-console/test/identity-backup.unit.test.ts diff --git a/ts/packages/web-console/src/adapters/identity-backup.ts b/ts/packages/web-console/src/adapters/identity-backup.ts new file mode 100644 index 00000000..abffaf91 --- /dev/null +++ b/ts/packages/web-console/src/adapters/identity-backup.ts @@ -0,0 +1,160 @@ +// A deliberate, consented copy of this device's identity key material, and its restoration. The backup is a JSON file the person saves themselves: it holds the private key as a standard JWK, so anything that reads ES256 JWKs can use it, and it is never logged or shown on screen. Restoring replaces the stored identity, and takes effect the next time the console loads. + +import { cdeDecodeOptions, cdeEncodeOptions, decode, encode } from "cbor2"; +import { bytesToHex, deviceIdToHex } from "wire-mesh-core/domain/device-id"; +import type { KeyValueStorage } from "wire-mesh-core/ports/storage"; +import { + IDENTITY_STORAGE_KEY, + deriveDeviceId, + isStoredIdentityEnvelope, +} from "./web-crypto-identity.js"; + +export const IDENTITY_BACKUP_FORMAT = "wire-mesh-console-identity"; +export const IDENTITY_BACKUP_VERSION = 1; +const ES256_NAME = "ES256"; + +/** The contents of a backup file. */ +export interface IdentityBackup { + format: typeof IDENTITY_BACKUP_FORMAT; + version: typeof IDENTITY_BACKUP_VERSION; + alg: typeof ES256_NAME; + /** Hex device-id this key derives, so a person can tell which identity a file holds without opening it. */ + deviceId: string; + /** Hex of the raw (uncompressed point) P-256 public key. */ + publicKey: string; + /** The private key. Whoever holds it can act as this device. */ + privateJwk: JsonWebKey; +} + +export interface IdentityBackupService { + /** + * The backup of the stored identity. + * @throws Error when no identity is stored, or the stored one is malformed. + */ + export: () => Promise; + /** Replaces the stored identity with the backup's, which applies the next time the console loads. */ + restore: (backup: Readonly) => Promise; +} + +const HEX_RADIX = 16; +const HEX_DIGITS_PER_BYTE = 2; +const UNCOMPRESSED_POINT_PREFIX = 4; + +function bytesFromHex(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / HEX_DIGITS_PER_BYTE); + for (let index = 0; index < bytes.length; index++) { + bytes[index] = Number.parseInt( + hex.slice(index * HEX_DIGITS_PER_BYTE, (index + 1) * HEX_DIGITS_PER_BYTE), + HEX_RADIX, + ); + } + return bytes; +} + +function fromBase64Url(text: string): Uint8Array { + const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/")); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); +} + +/** The raw uncompressed public point a P-256 private JWK carries in its x and y. */ +function publicPointOf(jwk: Readonly): Uint8Array { + if ( + jwk.kty !== "EC" || + jwk.crv !== "P-256" || + jwk.x === undefined || + jwk.y === undefined || + jwk.d === undefined + ) { + throw new Error("the backup does not hold a P-256 private key"); + } + return Uint8Array.from([ + UNCOMPRESSED_POINT_PREFIX, + ...fromBase64Url(jwk.x), + ...fromBase64Url(jwk.y), + ]); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Reads and checks the text of a backup file, including that its private key really is the one its public key and device-id name. + * @throws Error describing what is wrong with the file. + */ +export async function parseIdentityBackup( + text: string, +): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error("the file is not JSON"); + } + if ( + !isRecord(parsed) || + parsed.format !== IDENTITY_BACKUP_FORMAT || + parsed.version !== IDENTITY_BACKUP_VERSION || + parsed.alg !== ES256_NAME || + typeof parsed.deviceId !== "string" || + typeof parsed.publicKey !== "string" || + !isRecord(parsed.privateJwk) + ) { + throw new Error("the file is not a wire-mesh console identity backup"); + } + const privateJwk: JsonWebKey = parsed.privateJwk; + const point = publicPointOf(privateJwk); + if (bytesToHex(point) !== parsed.publicKey) { + throw new Error("the private key does not match the backup's public key"); + } + if (deviceIdToHex(await deriveDeviceId(point)) !== parsed.deviceId) { + throw new Error("the backup's device-id does not match its public key"); + } + return { + format: IDENTITY_BACKUP_FORMAT, + version: IDENTITY_BACKUP_VERSION, + alg: ES256_NAME, + deviceId: parsed.deviceId, + publicKey: parsed.publicKey, + privateJwk, + }; +} + +export function createIdentityBackupService( + storage: Readonly, +): IdentityBackupService { + return { + async export() { + const stored = await storage.get(IDENTITY_STORAGE_KEY); + if (stored === undefined) { + throw new Error("no identity is stored on this device"); + } + const envelope: unknown = decode(stored, cdeDecodeOptions); + if (!isStoredIdentityEnvelope(envelope)) { + throw new Error("stored identity envelope is malformed"); + } + return { + format: IDENTITY_BACKUP_FORMAT, + version: IDENTITY_BACKUP_VERSION, + alg: ES256_NAME, + deviceId: deviceIdToHex(await deriveDeviceId(envelope.publicKeyRaw)), + publicKey: bytesToHex(envelope.publicKeyRaw), + privateJwk: envelope.privateJwk, + }; + }, + async restore(backup) { + await storage.set( + IDENTITY_STORAGE_KEY, + new Uint8Array( + encode( + { + privateJwk: backup.privateJwk, + publicKeyRaw: bytesFromHex(backup.publicKey), + }, + cdeEncodeOptions, + ), + ), + ); + }, + }; +} diff --git a/ts/packages/web-console/src/adapters/web-crypto-identity.ts b/ts/packages/web-console/src/adapters/web-crypto-identity.ts index 5e5d382d..03528499 100644 --- a/ts/packages/web-console/src/adapters/web-crypto-identity.ts +++ b/ts/packages/web-console/src/adapters/web-crypto-identity.ts @@ -7,7 +7,8 @@ import type { KeyValueStorage } from "wire-mesh-core/ports/storage"; const ES256 = -7; const EDDSA = -8; -const IDENTITY_STORAGE_KEY = "web-console/identity/es256"; +/** Where the persisted identity envelope lives, shared with the backup service so both read and write one record. */ +export const IDENTITY_STORAGE_KEY = "web-console/identity/es256"; function algParams(alg: number): EcdsaParams | { name: "Ed25519" } { if (alg === ES256) { @@ -98,12 +99,12 @@ export async function createWebCryptoIdentity(): Promise { }; } -interface StoredIdentityEnvelope { +export interface StoredIdentityEnvelope { privateJwk: JsonWebKey; publicKeyRaw: Uint8Array; } -function isStoredIdentityEnvelope( +export function isStoredIdentityEnvelope( value: unknown, ): value is StoredIdentityEnvelope { if (typeof value !== "object" || value === null) return false; diff --git a/ts/packages/web-console/test/identity-backup.unit.test.ts b/ts/packages/web-console/test/identity-backup.unit.test.ts new file mode 100644 index 00000000..be2e77f8 --- /dev/null +++ b/ts/packages/web-console/test/identity-backup.unit.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage"; +import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; +import { + IDENTITY_BACKUP_FORMAT, + createIdentityBackupService, + parseIdentityBackup, +} from "../src/adapters/identity-backup.js"; +import { createPersistedWebCryptoIdentity } from "../src/adapters/web-crypto-identity.js"; + +const JSON_INDENT = 2; + +async function storedIdentity(): Promise<{ + storage: ReturnType; + deviceHex: string; +}> { + const storage = createMemoryStorage(); + const identity = await createPersistedWebCryptoIdentity(storage); + return { storage, deviceHex: deviceIdToHex(identity.deviceId) }; +} + +describe("identity backup", () => { + it("exports the stored identity with the device-id it derives", async () => { + const { storage, deviceHex } = await storedIdentity(); + + const backup = await createIdentityBackupService(storage).export(); + + expect(backup.format).toBe(IDENTITY_BACKUP_FORMAT); + expect(backup.deviceId).toBe(deviceHex); + expect(backup.privateJwk.kty).toBe("EC"); + expect(backup.privateJwk.d).toBeDefined(); + }); + + it("restores into empty storage so the same identity loads from it", async () => { + const { storage, deviceHex } = await storedIdentity(); + const text = JSON.stringify( + await createIdentityBackupService(storage).export(), + undefined, + JSON_INDENT, + ); + const fresh = createMemoryStorage(); + + await createIdentityBackupService(fresh).restore( + await parseIdentityBackup(text), + ); + + const restored = await createPersistedWebCryptoIdentity(fresh); + expect(deviceIdToHex(restored.deviceId)).toBe(deviceHex); + }); + + it("replaces an identity already stored, which is the one loaded next", async () => { + const original = await storedIdentity(); + const replacement = await storedIdentity(); + const backup = await createIdentityBackupService( + replacement.storage, + ).export(); + + await createIdentityBackupService(original.storage).restore(backup); + + expect( + deviceIdToHex( + (await createPersistedWebCryptoIdentity(original.storage)).deviceId, + ), + ).toBe(replacement.deviceHex); + }); + + it("has nothing to export when no identity is stored", async () => { + await expect( + createIdentityBackupService(createMemoryStorage()).export(), + ).rejects.toThrow("no identity is stored"); + }); +}); + +describe("parseIdentityBackup", () => { + async function exported(): Promise> { + const { storage } = await storedIdentity(); + const backup = await createIdentityBackupService(storage).export(); + return { ...backup }; + } + + it("refuses text that is not JSON, and JSON that is not a backup", async () => { + await expect(parseIdentityBackup("not json")).rejects.toThrow("not JSON"); + await expect(parseIdentityBackup("{}")).rejects.toThrow( + "not a wire-mesh console identity backup", + ); + }); + + it("refuses a backup whose public key is not the private key's", async () => { + const backup = await exported(); + const other = await exported(); + + await expect( + parseIdentityBackup( + JSON.stringify({ ...backup, publicKey: other.publicKey }), + ), + ).rejects.toThrow("does not match the backup's public key"); + }); + + it("refuses a backup whose device-id is not the public key's", async () => { + const backup = await exported(); + const other = await exported(); + + await expect( + parseIdentityBackup( + JSON.stringify({ ...backup, deviceId: other.deviceId }), + ), + ).rejects.toThrow("device-id does not match"); + }); + + it("refuses a backup holding no private key", async () => { + const backup = await exported(); + const jwk: Record = { ...(backup.privateJwk as object) }; + delete jwk.d; + + await expect( + parseIdentityBackup(JSON.stringify({ ...backup, privateJwk: jwk })), + ).rejects.toThrow("does not hold a P-256 private key"); + }); +}); From c63c26d8b531465d5eb6040f5388c332bea6c449 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Sep 2026 20:59:58 +0100 Subject: [PATCH 18/47] feat(web-console): identity and grants panels, with revocations honoured across token checks The identity panel shows the device-id and offers the key backup behind a warning and explicit acknowledgement. The grants panel lists the grants this device holds and has issued, each judged afresh by core's verifier, mints grants with mintCapabilityToken (delegations may only narrow their parent), adds a grant from its copied grant code, and revokes issued grants with mintRevocationEntry after a confirmation. Revocations minted here or announced by a connected node are verified, persisted and consulted by every token check in the console, replacing the check that treated every token as unrevoked. Tokens received by a join are recorded as held and tokens an accepted join issues as issued. --- ts/packages/web-console/live-check/harness.ts | 1 + ts/packages/web-console/src/App.tsx | 48 +++- .../web-console/src/capability-services.ts | 9 + .../src/components/GrantDetails.tsx | 78 ++++++ .../web-console/src/components/GrantTable.tsx | 128 ++++++++++ .../src/components/GrantsPanel.tsx | 52 ++++ .../src/components/IdentityPanel.tsx | 238 ++++++++++++++++++ .../src/components/IdentitySection.tsx | 55 ++++ .../src/components/ImportGrantForm.tsx | 61 +++++ .../src/components/MintGrantForm.tsx | 159 ++++++++++++ .../web-console/src/components/RoomPanel.tsx | 1 + ts/packages/web-console/src/grant-store.ts | 117 +++++++++ ts/packages/web-console/src/grants.ts | 117 +++++++++ .../web-console/src/hooks/use-grants.ts | 139 ++++++++++ .../src/hooks/use-room-messaging.ts | 21 +- ts/packages/web-console/src/main.tsx | 18 +- ts/packages/web-console/src/mint-grant.ts | 112 +++++++++ .../web-console/src/revocation-store.ts | 105 ++++++++ ts/packages/web-console/src/room-client.ts | 22 +- ts/packages/web-console/src/save-file.ts | 13 + .../web-console/src/webrtc-negotiation.ts | 11 +- .../web-console/test/App.unit.test.tsx | 20 +- .../test/IdentityPanel.unit.test.tsx | 177 +++++++++++++ .../test/IdentitySection.unit.test.tsx | 237 +++++++++++++++++ .../web-console/test/capability-services.ts | 33 +++ .../test/e2e/identity-grants.e2e.spec.ts | 93 +++++++ .../web-console/test/grant-store.unit.test.ts | 109 ++++++++ .../web-console/test/grants.unit.test.ts | 114 +++++++++ .../test/jsdom-mantine-polyfills.ts | 12 +- .../web-console/test/mint-grant.unit.test.ts | 166 ++++++++++++ .../test/revocation-store.unit.test.ts | 123 +++++++++ .../use-room-messaging-relay.unit.test.tsx | 90 ++++++- .../test/use-room-messaging.unit.test.tsx | 4 +- .../test/webrtc-negotiation.unit.test.ts | 3 + 34 files changed, 2660 insertions(+), 26 deletions(-) create mode 100644 ts/packages/web-console/src/capability-services.ts create mode 100644 ts/packages/web-console/src/components/GrantDetails.tsx create mode 100644 ts/packages/web-console/src/components/GrantTable.tsx create mode 100644 ts/packages/web-console/src/components/GrantsPanel.tsx create mode 100644 ts/packages/web-console/src/components/IdentityPanel.tsx create mode 100644 ts/packages/web-console/src/components/IdentitySection.tsx create mode 100644 ts/packages/web-console/src/components/ImportGrantForm.tsx create mode 100644 ts/packages/web-console/src/components/MintGrantForm.tsx create mode 100644 ts/packages/web-console/src/grant-store.ts create mode 100644 ts/packages/web-console/src/grants.ts create mode 100644 ts/packages/web-console/src/hooks/use-grants.ts create mode 100644 ts/packages/web-console/src/mint-grant.ts create mode 100644 ts/packages/web-console/src/revocation-store.ts create mode 100644 ts/packages/web-console/src/save-file.ts create mode 100644 ts/packages/web-console/test/IdentityPanel.unit.test.tsx create mode 100644 ts/packages/web-console/test/IdentitySection.unit.test.tsx create mode 100644 ts/packages/web-console/test/capability-services.ts create mode 100644 ts/packages/web-console/test/e2e/identity-grants.e2e.spec.ts create mode 100644 ts/packages/web-console/test/grant-store.unit.test.ts create mode 100644 ts/packages/web-console/test/grants.unit.test.ts create mode 100644 ts/packages/web-console/test/mint-grant.unit.test.ts create mode 100644 ts/packages/web-console/test/revocation-store.unit.test.ts diff --git a/ts/packages/web-console/live-check/harness.ts b/ts/packages/web-console/live-check/harness.ts index 1990fb5d..a171e85f 100644 --- a/ts/packages/web-console/live-check/harness.ts +++ b/ts/packages/web-console/live-check/harness.ts @@ -141,6 +141,7 @@ window.harness = { const negotiator = createWebrtcNegotiator(session, { identity, clock, + revocation: { entriesFor: async () => Promise.resolve([]) }, // Both browsers of the check run on one machine, where host candidates are all that is needed, and the check must not depend on reaching an outside server. iceServers: [], onIncomingConnection: (connection) => { diff --git a/ts/packages/web-console/src/App.tsx b/ts/packages/web-console/src/App.tsx index 1137dfd8..9eb169d8 100644 --- a/ts/packages/web-console/src/App.tsx +++ b/ts/packages/web-console/src/App.tsx @@ -1,6 +1,6 @@ // Top-level layout: the connect form, the list of currently open relay connection panels, and every peer-to-peer room-messaging panel. Owns only the set of live sessions and negotiators -- everything about rendering one relay session's own state lives in ConnectionPanel, and everything about a room session's own state lives in the useRoomMessaging hook plus RoomPanel. -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Alert, Button, @@ -22,6 +22,10 @@ import type { Clock } from "wire-mesh-core/ports/clock"; import type { Connection } from "wire-mesh-core/ports/transport"; import type { KeyValueStorage } from "wire-mesh-core/ports/storage"; import type { DeviceId } from "wire-mesh-core/generated/protocol"; +import type { + DeviceId, + RevocationEntry, +} from "wire-mesh-core/generated/protocol"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import { dmRoomPath } from "wire-mesh-core/domain/room-path"; import { reconnectPolicy } from "./reconnect-policy.js"; @@ -43,6 +47,10 @@ import { PeerName } from "./components/PeerName.js"; import { SelfNameField } from "./components/SelfNameField.js"; import { OnboardingIntro } from "./components/OnboardingIntro.js"; import { SearchPanel } from "./components/SearchPanel.js"; +import { IdentitySection } from "./components/IdentitySection.js"; +import type { IdentityBackupService } from "./adapters/identity-backup.js"; +import type { GrantStore } from "./grant-store.js"; +import type { RevocationStore } from "./revocation-store.js"; import { useIntro } from "./hooks/use-intro.js"; import type { PreferencesStore } from "./preferences-store.js"; import type { NameStore } from "./name-store.js"; @@ -69,6 +77,12 @@ export interface AppProps { nameStore: NameStore; /** Interface preferences kept in the console's own storage, such as whether the first-run intro has been dismissed. */ preferences: PreferencesStore; + /** The grants this device holds and has issued, kept in the console's own storage. */ + grants: GrantStore; + /** The revocations this device has made or heard, which every token check in the console consults. */ + revocations: RevocationStore; + /** Backs up and restores this device's identity key material. */ + identityBackup: IdentityBackupService; /** Attempts same-device node auto-discovery once, on mount. Defaults to the real `discoverLocalNode` (a no-op when this console is served from a loopback origin, a real localhost probe otherwise); tests inject a fake to avoid depending on `location`/`fetch`. */ discoverLocalNode?: () => Promise; // Seeds the Node field. Left as a prop (rather than App reading location/import.meta.env itself) so App stays the plain, testable component its own header comment describes; main.tsx computes the real value via default-hub-address.ts. @@ -108,6 +122,9 @@ export function App({ certificateMemory, nameStore, preferences, + grants, + revocations, + identityBackup, discoverLocalNode = discoverLocalNodeDefault, defaultAddress = DEFAULT_ADDRESS, }: Readonly): React.JSX.Element { @@ -118,11 +135,16 @@ export function App({ const [failures, setFailures] = useState([]); const intro = useIntro(preferences); const trust = useCertificateTrust(certificateMemory, clock); + const capabilities = useMemo( + () => ({ revocation: revocations.view, grants }), + [revocations, grants], + ); const roomMessaging = useRoomMessaging( identity, clock, messageStore, roomStorage, + capabilities, ); const [selectedPath, setSelectedPath] = useState(); // The conversation shown is the one the user picked, falling back to the first while nothing is picked or the picked one no longer exists. @@ -179,12 +201,19 @@ export function App({ { identity, clock, + revocation: capabilities.revocation, iceServers: DEFAULT_ICE_SERVERS, onIncomingConnection: (connection: Readonly) => { void attachRef.current(connection); }, }, ); + // Revocations a node announces are verified and recorded by the store, so every token check in the console honours them. + void (async (): Promise => { + for await (const entry of session.revocationAnnouncements) { + await revocations.ingest(entry); + } + })(); watchHubRef.current({ sendManageRequest: async (...args) => session.sendManageRequest(...args), incomingManageRequests: demux.stream(ROOM_MEMBER_CAPABILITY), @@ -325,6 +354,15 @@ export function App({ connectTo(address); } + /** Tells every connected node about a revocation. A connection that is down cannot be told, so it is skipped rather than failing the revocation, which has already been recorded here. */ + async function announceRevocation(entry: RevocationEntry): Promise { + await Promise.allSettled( + connectionsRef.current.map(async (connection) => + connection.session.sendRevocationAnnounce([entry]), + ), + ); + } + function handleClose(target: Readonly): void { roomMessaging.dropHub(target.session); void target.session.close(); @@ -448,6 +486,14 @@ export function App({ conversations={roomMessaging.conversations} onSelect={setSelectedPath} /> + ): React.JSX.Element { + return ( + + + {label}: + {" "} + {children} + + ); +} + +export function GrantDetails({ + row, +}: Readonly<{ row: GrantRow }>): React.JSX.Element { + const { claims } = row; + const code = encodeGrantCode(row.token); + return ( + + + {row.tokenId} + + + + + + + + + {claims.capability} over {claims.scope.kind} + {claims.scope.path === undefined ? "" : ` ${claims.scope.path}`} + + {new Date(claims.expires).toISOString()} + {claims["not-before"] !== undefined && ( + + {new Date(claims["not-before"]).toISOString()} + + )} + {claims["delegations-remaining"] !== undefined && ( + + {claims["delegations-remaining"]} + + )} + {claims.parent !== undefined && ( + + another grant (its parent rides inside this one) + + )} + {describeGrantStatus(row.status)} +