diff --git a/src/core/bridge-mesh.ts b/src/core/bridge-mesh.ts index d6dc311b..e8ae461d 100644 --- a/src/core/bridge-mesh.ts +++ b/src/core/bridge-mesh.ts @@ -41,6 +41,8 @@ export function createBridgeMeshSync( store.roomVerbHandlers, undefined, () => store.selfStatus, + undefined, + () => store.hostedRooms, ), ); const tool = new CommsTool(store, store.discovery); diff --git a/src/core/mesh-store.ts b/src/core/mesh-store.ts index d417e7a8..0b30db84 100644 --- a/src/core/mesh-store.ts +++ b/src/core/mesh-store.ts @@ -29,6 +29,7 @@ import { ConnectionApproval } from "./connection-approval.js"; import { StaleAgentChecker } from "./stale-agent-checker.js"; import { PeerLifecycle } from "./peer-lifecycle.js"; import type { RoomVerbHandler } from "./room-router.js"; +import type { HostedRoomAdvert } from "./wire-mesh-transport.js"; import type { MeshStatePatch, PeerInfo, @@ -107,6 +108,22 @@ export class MeshStore implements CommsStore { return this.agents.get(this.peerId)?.status; } + /** This store's own currently-hosted public/private rooms, synchronously, in the lightweight gossip-safe shape WireMeshTransport's own hosted-rooms re-advertisement timer reads on every tick -- the write half of P3.8's room-discovery replacement for createRoom's own broadcastPatch (agent-comms#48). Secret rooms are never included (never worth advertising at all), and a room this store has merely replicated via the legacy room_upsert broadcast, rather than owns, is excluded too -- "hosted" means "this device is the one a joiner should actually reach", which only its own owner is. */ + get hostedRooms(): readonly HostedRoomAdvert[] { + const result: HostedRoomAdvert[] = []; + for (const room of this.rooms.values()) { + if (room.owner !== this.peerId) continue; + if (room.type !== "public" && room.type !== "private") continue; + result.push({ + path: room.id, + name: room.name, + type: room.type, + description: room.description, + }); + } + return result; + } + /** Whether the mesh has a live coordinator connection. */ get connected(): boolean { return ( diff --git a/src/test/mesh-store-orchestration.test.ts b/src/test/mesh-store-orchestration.test.ts index 3a776495..936a80da 100644 --- a/src/test/mesh-store-orchestration.test.ts +++ b/src/test/mesh-store-orchestration.test.ts @@ -6,6 +6,7 @@ import { MeshStore } from "../core/mesh-store.js"; import type { ConnectionHandle, MeshTransport } from "../core/transport.js"; import type { ManageOutcome } from "wire-mesh-core/domain/mesh-session"; import type { PeerInfo } from "../core/wire-protocol.js"; +import type { Room } from "../core/types.js"; /** A device-id is a 64-character lowercase hex SHA-256 digest; room-path.ts's assertDeviceIdHex rejects anything shorter. */ const DEVICE_ID_HEX_LENGTH = 64; @@ -95,6 +96,90 @@ describe("MeshStore — connected getter", () => { }); }); +describe("MeshStore — hostedRooms getter", () => { + function room(overrides: Partial = {}): Room { + return { + id: "owner-device/general", + version: 1, + name: "general", + type: "public", + owner: "owner-device", + createdAt: "2026-01-01T00:00:00.000Z", + description: "chat", + members: ["owner-device"], + invited: [], + memberJoins: { "owner-device": 1 }, + memberLeaves: {}, + invitedJoins: {}, + invitedLeaves: {}, + ...overrides, + }; + } + + it("includes only this store's own public/private rooms, mapped to the gossip-safe shape", () => { + const store = new MeshStore(); + store.peerId = "owner-device"; + const rooms = collaborator(store, "rooms") as Map; + rooms.set( + "owner-device/general", + room({ id: "owner-device/general", name: "general", type: "public" }), + ); + rooms.set( + "owner-device/team", + room({ + id: "owner-device/team", + name: "team", + type: "private", + description: "private chat", + }), + ); + + expect(store.hostedRooms).toEqual([ + { + path: "owner-device/general", + name: "general", + type: "public", + description: "chat", + }, + { + path: "owner-device/team", + name: "team", + type: "private", + description: "private chat", + }, + ]); + }); + + it("excludes secret rooms, even when owned by this store", () => { + const store = new MeshStore(); + store.peerId = "owner-device"; + const rooms = collaborator(store, "rooms") as Map; + rooms.set( + "owner-device/_hidden", + room({ id: "owner-device/_hidden", name: "_hidden", type: "secret" }), + ); + + expect(store.hostedRooms).toEqual([]); + }); + + it("excludes rooms owned by a different device, even public/private ones this store has replicated", () => { + const store = new MeshStore(); + store.peerId = "owner-device"; + const rooms = collaborator(store, "rooms") as Map; + rooms.set( + "other-device/general", + room({ + id: "other-device/general", + owner: "other-device", + name: "general", + type: "public", + }), + ); + + expect(store.hostedRooms).toEqual([]); + }); +}); + describe("MeshStore — init()", () => { it("starts the data server, records its own peer info, connects to an existing coordinator, and unrefs -- without becoming coordinator", async () => { const store = new MeshStore();