Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
812fe8b
feat(web-console): name peers by petname, self display name or short id
Mearman Sep 30, 2026
fcd66a7
fix(web-console): show the short id beside a self-asserted name and s…
Mearman Sep 30, 2026
ed98026
fix(web-console): retract a published display name when it is cleared…
Mearman Sep 30, 2026
c7095a7
fix(web-console): drop a peer's name once it stops advertising one an…
Mearman Sep 30, 2026
ef08dc9
fix(web-console): mark a self-asserted name as the peer's own claim a…
Mearman Oct 1, 2026
cee4395
fix(web-console): apply petname and self-name writes in the order the…
Mearman Oct 1, 2026
97911f5
test(web-console): render the local-network panel test inside the nam…
Mearman Oct 1, 2026
9d4034a
feat(web-console): confirm first-use certificates, warn on changes, s…
Mearman Sep 30, 2026
2229804
feat(web-console): humanised activity view and connection health
Mearman Sep 30, 2026
2f8983d
docs(web-console): reword the session events hook header without a sp…
Mearman Sep 30, 2026
c5b2c3b
test(web-console): accept the first-use certificate prompt in the pin…
Mearman Sep 30, 2026
a9588a8
test(web-console): open the raw frame log before counting handshakes …
Mearman Sep 30, 2026
695b8b5
test(web-console): make the unresponsive-node probe test independent …
Mearman Sep 30, 2026
db3bcd5
fix(web-console): name the peer in the conversation header and messag…
Mearman Sep 30, 2026
4a8af37
feat(web-console): first-run intro, conversation search and phone-wid…
Mearman Sep 30, 2026
2619026
docs(web-console): describe naming, trust, activity, first run and se…
Mearman Sep 30, 2026
bbfe0c3
feat(web-console): back up and restore the device identity key
Mearman Sep 30, 2026
c63c26d
feat(web-console): identity and grants panels, with revocations honou…
Mearman Sep 30, 2026
176af6c
docs(web-console): describe identity backup, grants and revocation re…
Mearman Sep 30, 2026
f49d321
docs(web-console): describe all three jsdom stubs in the Mantine poly…
Mearman Sep 30, 2026
cb68f62
fix(web-console): dial a known node with only its remembered pins and…
Mearman Sep 30, 2026
16e9b44
fix(core): reject an unanswered ping with a typed PingTimeoutError
Mearman Sep 30, 2026
c12448c
fix(web-console): count only unanswered pings as lost, not probes cut…
Mearman Sep 30, 2026
abc77bd
fix(web-console): report each peer as seen once in the activity view
Mearman Sep 30, 2026
2886c49
fix(web-console): draw a dot for every measured round trip so a lone …
Mearman Sep 30, 2026
687c1cf
docs(web-console): describe certificate pinning and naming as they be…
Mearman Sep 30, 2026
23c0ecc
fix(web-console): report a first-run intro setting that cannot be rea…
Mearman Sep 30, 2026
5167609
test(web-console): assert the message request names the requester wit…
Mearman Sep 30, 2026
133b109
fix(web-console): keep table roles on the stacked phone layout
Mearman Sep 30, 2026
1446afb
fix(web-console): close every open session when the console unmounts
Mearman Sep 30, 2026
3d61cad
fix(web-console): refuse an identity backup whose private key cannot …
Mearman Oct 1, 2026
e56dfaf
fix(web-console): mint grants with whole-number expiry and delegation…
Mearman Oct 1, 2026
5eb9419
fix(web-console): start with unreadable stored revocations reported a…
Mearman Oct 1, 2026
8a11c6b
fix(web-console): report failed restores, revocations and writes, and…
Mearman Oct 1, 2026
c35478d
fix(web-console): forget a gossiped candidate's address claims once i…
Mearman Oct 1, 2026
117c389
fix(web-console): let only the live prompt for a certificate question…
Mearman Oct 1, 2026
2d6be05
test(web-console): list every gossiped candidate before dialling one …
Mearman Oct 1, 2026
d6b11f3
test(web-console): pass the clock to the certificate trust hook in th…
Mearman Oct 1, 2026
62698df
test(web-console): pin the reconnect countdown to the attempt's own b…
Mearman Oct 1, 2026
2209c0c
docs(core): split the sendPingMeasureRtt contract into paragraphs wit…
Mearman Oct 1, 2026
eedc022
fix(web-console): label the directory time as when the peer advertise…
Mearman Oct 1, 2026
8daef7f
fix(web-console): write a self-asserted name with its short id in the…
Mearman Oct 1, 2026
971d856
fix(web-console): show the first-run intro when its stored answer can…
Mearman Oct 1, 2026
4090d26
fix(web-console): store only the defining members of a restored ident…
Mearman Oct 1, 2026
17e39dc
test(web-console): cover revocation wiring in App and share the App t…
Mearman Oct 1, 2026
ab0960a
fix(web-console): honour recorded revocations when restoring a persis…
Mearman Oct 1, 2026
dc3c077
test(web-console): give the shared harness and call sites the props t…
Mearman Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion ts/packages/core/src/domain/mesh-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,17 @@ export interface MeshSession {
readonly coordinatorFrames: AsyncIterable<CoordinatorFrame>;
connect: (address: string, localDomains: readonly string[]) => Promise<void>;
sendPing: () => Promise<void>;
/** Sends a ping-frame and resolves with the round-trip time in milliseconds once the correlated pong-frame arrives -- FIFO-paired against this call's own ping, since ping-frame carries no correlation id of its own (spec/transport.cddl): the Nth call's own promise resolves against the Nth pong received after it, never matched by any other means. Rejects if the connection closes, or (when timeoutMs is given) if no pong arrives within timeoutMs, rather than resolving a sentinel value the way sendManageRequest's own timeout does -- there is no natural "no answer" value for a bare millisecond count to double as. Unlike sendPing (fire-and-forget, answered by nothing on an ordinary peer connection), this is answered only by a peer that replies to ping with pong -- today, relay-hub's own frame handling (wire-mesh#181) -- so calling this against a connection to a plain peer that never sends pong hangs until timeoutMs (if given) or forever. Exists to isolate the sender-to-hub leg of a relayed path.trace round trip: time this over the same connection a relayed manage-request travelled, then subtract it from path.trace's own end-to-end RTT to recover the hub-to-target leg. */
/**
* Sends a ping-frame and resolves with the round-trip time in milliseconds once the correlated pong-frame arrives.
*
* The pairing is FIFO because ping-frame carries no correlation id of its own (spec/transport.cddl): the Nth call's promise resolves against the Nth pong received after it, never matched by any other means.
*
* Rejects with a plain Error if the connection closes, and with a PingTimeoutError (from ping-round-trips.ts) when `timeoutMs` is given and no pong arrives within it, so a caller can tell an unanswered ping from a dropped connection. It never resolves a sentinel the way sendManageRequest's own timeout does, because there is no natural "no answer" value for a bare millisecond count to double as.
*
* Unlike sendPing (fire-and-forget, answered by nothing on an ordinary peer connection), this is answered only by a peer that replies to ping with pong, which today means relay-hub's own frame handling (wire-mesh#181). Called against a plain peer that never sends pong it hangs until `timeoutMs` if given, or forever.
*
* It exists to isolate the sender-to-hub leg of a relayed path.trace round trip: time it over the same connection a relayed manage-request travelled, then subtract it from path.trace's own end-to-end RTT to recover the hub-to-target leg.
*/
sendPingMeasureRtt: (timeoutMs?: number) => Promise<number>;
/** Attaches this token to every `manage-request` sent from now on. */
setToken: (token: CapabilityToken) => void;
Expand Down
12 changes: 10 additions & 2 deletions ts/packages/core/src/domain/ping-round-trips.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
// FIFO pairing between sendPingMeasureRtt calls and the pong-frames that answer them (wire-mesh#181) -- ping-frame/pong-frame carry no correlation id of their own (spec/transport.cddl), so the Nth outstanding call is paired against the Nth pong a connection receives. Extracted out of mesh-session.ts the same way relay-pairing.ts already extracts its own relay-pairing bookkeeping, so mesh-session.ts's own applyFrame/sendPingMeasureRtt stay thin wiring rather than owning this queue's mechanics directly.
// FIFO pairing between sendPingMeasureRtt calls and the pong-frames that answer them (wire-mesh#181). ping-frame and pong-frame carry no correlation id of their own (spec/transport.cddl), so the Nth outstanding call is paired against the Nth pong a connection receives. Extracted out of mesh-session.ts the same way relay-pairing.ts already extracts its own relay-pairing bookkeeping, so mesh-session.ts's own applyFrame/sendPingMeasureRtt stay thin wiring rather than owning this queue's mechanics directly.

/** Why a ping was given up on: no pong arrived within the caller's `timeoutMs`. A call rejected for any other reason (the connection dropped or closed first) rejects with a plain Error, so a caller counting unanswered pings tells the two apart by this class rather than by message text. */
export class PingTimeoutError extends Error {
constructor() {
super("timed out waiting for pong");
this.name = "PingTimeoutError";
}
}

interface PendingPingRoundTrip {
sentAt: number;
Expand Down Expand Up @@ -58,7 +66,7 @@ export function createPingRoundTrips(): PingRoundTrips {
new Promise<number>((_resolve, reject) => {
setTimeout(() => {
if (cancel(sentAt)) {
reject(new Error("timed out waiting for pong"));
reject(new PingTimeoutError());
}
}, timeoutMs);
}),
Expand Down
4 changes: 3 additions & 1 deletion ts/packages/core/test/mesh-session-ping-pong.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { Clock } from "../src/ports/clock.js";
import { createMeshSession } from "../src/domain/mesh-session.js";
import { PingTimeoutError } from "../src/domain/ping-round-trips.js";
import {
fakeTransport,
testIdentity,
Expand Down Expand Up @@ -82,6 +83,7 @@ describe("sendPingMeasureRtt", () => {
const pending = session.sendPingMeasureRtt();
connection.fail(new Error("dropped"));
await expect(pending).rejects.toThrow("disconnected before a pong arrived");
await expect(pending).rejects.not.toBeInstanceOf(PingTimeoutError);
});

it("times out rather than hanging forever when no pong ever arrives", async () => {
Expand All @@ -97,7 +99,7 @@ describe("sendPingMeasureRtt", () => {
.catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(PING_ROUND_TRIP_TIMEOUT_MS);
const outcome = await pending;
expect(outcome).toBeInstanceOf(Error);
expect(outcome).toBeInstanceOf(PingTimeoutError);
expect((outcome as Error).message).toBe("timed out waiting for pong");
await session.close();
} finally {
Expand Down
10 changes: 7 additions & 3 deletions ts/packages/web-console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ 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, and when the peer advertised itself by its own clock), 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 (always shown with the short id beside it, since a stranger chooses the name and two peers can claim the same one), 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, dialled with only the presented certificates that are already remembered. A connected node that announces certificates unlike the remembered ones is reported and then pinned: the announcement arrived on a session this console had already pinned, so it is applied, with a dismissable notice. Each discovered-peer row names the connection whose directory gossiped it, and the device the gossip claimed for that connection's address is labelled as a claim, since nothing on the connection authenticates 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.
- **Identity and grants**: the identity panel shows this device's id and offers a deliberate backup of its key material: a file the person saves through the browser (`src/adapters/identity-backup.ts`), behind a warning and an explicit acknowledgement, never shown on the page or written to a log, and restorable from the same file after both identities are named. The grants panel lists the capability grants this device holds and has issued (`src/grant-store.ts`), each judged afresh by core's verifier, and mints grants with core's `mintCapabilityToken` (a delegation may only narrow the grant it is made from), adds a grant from its copied grant code, and revokes one it issued with `mintRevocationEntry`.
- **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

Expand Down Expand Up @@ -44,7 +48,7 @@ Messages travel over `core/room`. A conversation is identified by its room path:
## What is deliberately deferred

- **Group rooms** — direct messages between two peers are supported, but creating, inviting to and joining named multi-member rooms has client logic in `src/room-client.ts` and no UI yet.
- **Revocation-gossip ingestion** — the console has no way to learn a capability token was revoked after it was issued, so `webrtc-negotiation.ts`'s token check treats every otherwise-valid token as unrevoked (see the `noRevocationCheck` comment there) — an explicit, documented limitation, not a silent gap.
- **Relaying revocations** — the console records and honours the revocations it mints and the ones a connected node announces (`src/revocation-store.ts`), but a revocation reaches a peer only if a node relays it, and the hub forwards nothing but gossip, relay frames and their kin. A revoked grant is therefore refused here at once and elsewhere only where a revocation was heard; relaying revocation announcements is tracked in ExaDev/wire-mesh#314.
- **TURN**: the console is configured with a STUN server (`src/ice-servers.ts`) so a direct connection can form across NATs, and with no TURN server. Where no direct path exists a conversation is meant to fall back to the hub relay rather than a second relay service.
- **TLS in dev** — `ws://` against localhost is fine; production deployments serve the console over HTTPS and dial `wss://`, which the adapter already handles.

Expand Down
1 change: 1 addition & 0 deletions ts/packages/web-console/live-check/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
54 changes: 52 additions & 2 deletions ts/packages/web-console/src/App.css.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,58 @@
// The one piece of layout Mantine's own style props don't cover cleanly: capping the console's own content width and centring it on a wide viewport, so the peer directory and frame log tables don't stretch edge-to-edge on a desktop monitor.
// The layout Mantine's own style props don't cover cleanly: capping the console's own content width and centring it on a wide viewport, so the peer tables don't stretch edge-to-edge on a desktop monitor, and turning a peer table into a stack of labelled cards on a phone.

import { style } from "@vanilla-extract/css";
import { globalStyle, style } from "@vanilla-extract/css";

export const appShell = style({
maxWidth: "64rem",
marginInline: "auto",
// A long address or device-id with no break opportunity would otherwise widen the page past a phone's viewport.
overflowWrap: "anywhere",
});

/** Mantine's `xs` breakpoint (the same 36em vite.config.ts hands the PostCSS pipeline), below which a peer table no longer fits a row of columns. */
const PHONE_MEDIA = "screen and (max-width: 36em)";

/** A table whose rows become cards on a phone: the header row is hidden and each cell shows its column's name (its `data-label`) above its value. Use it through `StackedTable`, which also states each element's ARIA role, because changing `display` on table elements makes Safari drop their table semantics. */
export const stackedTable = style({});

globalStyle(`${stackedTable} thead`, {
"@media": {
[PHONE_MEDIA]: {
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
clip: "rect(0 0 0 0)",
},
},
});

globalStyle(`${stackedTable} tr`, {
"@media": {
[PHONE_MEDIA]: {
display: "block",
paddingBlock: "0.5rem",
},
},
});

globalStyle(`${stackedTable} td`, {
"@media": {
[PHONE_MEDIA]: {
display: "block",
border: "none",
paddingBlock: "0.125rem",
},
},
});

globalStyle(`${stackedTable} td::before`, {
"@media": {
[PHONE_MEDIA]: {
content: "attr(data-label)",
display: "block",
fontSize: "0.75rem",
opacity: 0.7,
},
},
});
Loading
Loading