feat(uniweb): readable handle instead of endpoint id in messages

Resolve a message sender's endpoint id to a human handle using a new
control-plane directory endpoint.

- ControlPlane.fetchDirectory(): signed GET /api/directory, mapped to
  DirectoryEntry { signPub, endpoint, handle, role }. The server's endpoint
  matches endpointID(signPub) byte for byte.
- busService keeps an endpoint -> handle Map, loaded once after a session
  opens and refreshed after createRoom (where the ACL is already refreshed).
  Exposes a pure displayName(endpoint) resolver: handle when known, the
  session user's own handle for their messages, short id fallback otherwise.
- Resilience: loadDirectory never throws. A missing endpoint (404 on older
  clusters) or a transient error leaves the map empty and the UI falls back to
  the short id, so the chat keeps working exactly as before.
- ChatPanel renders displayName(msg.sender) in the message header and derives
  the avatar initials from the handle; the raw endpoint stays in a title
  tooltip for debugging.
- types: Message.sender comment updated (this is the "phase 2" readable name).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 15:27:15 +02:00
parent 103a7f2f05
commit 5e9bf4e777
4 changed files with 94 additions and 4 deletions
+38
View File
@@ -176,6 +176,29 @@ interface MemberJSON {
sign_pub: string; // base64
}
// DirectoryMemberWire is one row of GET /directory: a cluster-wide member with its
// human handle and role. sign_pub here is 64-hex (the raw Ed25519 public key), and
// endpoint matches endpointID(signPub) byte for byte.
interface DirectoryMemberWire {
sign_pub: string; // 64-hex
endpoint: string; // base64url-nopad, == endpointID(signPub)
handle: string;
role: string;
}
interface DirectoryResp {
members: DirectoryMemberWire[];
}
// DirectoryEntry is the SDK shape of one directory member: the readable handle keyed
// by the stable endpoint id, so the UI can show a name instead of the raw id.
export interface DirectoryEntry {
signPub: string; // 64-hex
endpoint: string;
handle: string;
role: string;
}
// MemberRoomWire is one row of GET /members/{endpoint}/rooms.
interface MemberRoomWire {
room_id: string;
@@ -285,6 +308,21 @@ export class ControlPlane {
}));
}
// fetchDirectory returns the cluster-wide member directory (GET /api/directory), so
// the UI can resolve a message sender's endpoint id to a readable handle. The
// request is signed like every other control-plane call. The caller is expected to
// tolerate this endpoint being absent on older clusters (404) and fall back to the
// short id; this method only maps the wire shape and lets transport errors surface.
async fetchDirectory(): Promise<DirectoryEntry[]> {
const resp = await this.request<DirectoryResp>("GET", "/directory");
return (resp.members ?? []).map((m) => ({
signPub: m.sign_pub,
endpoint: m.endpoint,
handle: m.handle,
role: m.role,
}));
}
// listMembers returns the room's members keyed by endpoint, so a receiver can find
// a sender's signing public key to verify message signatures.
async signerKeys(roomID: string): Promise<Map<string, Uint8Array>> {