diff --git a/src/platform/api.ts b/src/platform/api.ts index bf52c80..2cc1036 100644 --- a/src/platform/api.ts +++ b/src/platform/api.ts @@ -12,7 +12,9 @@ import { readControlPlaneRoom } from "./registry.js"; import { resolveOwnerAccount, type PlatformOwnerQuery } from "./accounts.js"; -import type { ControlPlaneRoom } from "./types.js"; +import type { ControlPlaneRoom, PublicChannel } from "./types.js"; +import { readBoardroom } from "../storage/index.js"; +import { DEFAULT_CHANNEL_ID, DEFAULT_CHANNEL_NAME } from "../protocol/index.js"; export interface PlatformApiResponse { status: number; @@ -21,12 +23,16 @@ export interface PlatformApiResponse { export type PlatformApiQuery = PlatformOwnerQuery; +/** Public shape of a hosted room: control-plane metadata plus sanitized channels. */ +export type PublicRoom = ControlPlaneRoom & { channels: PublicChannel[] }; + /** GET-style handler: list the central metadata for an owner's rooms. */ export async function listRoomsResponse(root: string, query: PlatformApiQuery): Promise { const owner = ownerOrError(query); if (owner === null) return unauthorized(); const rooms = (await listControlPlaneRooms(root)).filter((room) => room.owner_user_id === owner); - return { status: 200, body: { ok: true, rooms } }; + const withChannels = await Promise.all(rooms.map((room) => attachChannels(root, room))); + return { status: 200, body: { ok: true, rooms: withChannels } }; } /** GET-style handler: read one room's central metadata for an owner. */ @@ -47,7 +53,34 @@ export async function readRoomResponse( // A non-owner is told the room does not exist rather than that it is hidden, // so the API never confirms the existence of another owner's room. if (room.owner_user_id !== owner) return notFound(); - return { status: 200, body: { ok: true, room } }; + return { status: 200, body: { ok: true, room: await attachChannels(root, room) } }; +} + +// Attach the room's sanitized channel list to its control-plane metadata. Channels +// are read from the host-owned boardroom store and reduced to exactly {id, name, +// type}; a room with no boardroom store record (e.g. a legacy bare room whose store +// was never materialized) falls back to a single #general chat channel, matching +// the room server's own runtime projection. Channel reads never widen the payload: +// no token, invite/card URL, lifecycle, cursor, or message content crosses over. +async function attachChannels(root: string, room: ControlPlaneRoom): Promise { + let boardroom; + try { + boardroom = await readBoardroom(root, room.room_id); + } catch (error) { + // No host boardroom store or room-state record for this room: project the + // legacy default, exactly as the host room server does at runtime. Only a + // genuine "no store" (ENOENT) falls back; any other failure (e.g. a corrupt + // store) is a real error and propagates as a 500 rather than being masked. + const isMissingStore = error instanceof Error && "code" in error && (error as { code?: unknown }).code === "ENOENT"; + if (isMissingStore) { + return { ...room, channels: [{ id: DEFAULT_CHANNEL_ID, name: DEFAULT_CHANNEL_NAME, type: "chat" }] }; + } + throw error; + } + const channels = boardroom.channels + .filter((channel) => channel.lifecycle !== "removed") + .map((channel) => ({ id: channel.id, name: channel.name, type: channel.type })); + return { ...room, channels }; } function ownerOrError(query: PlatformApiQuery): string | null { diff --git a/src/platform/types.ts b/src/platform/types.ts index 7dd9e23..af2086d 100644 --- a/src/platform/types.ts +++ b/src/platform/types.ts @@ -6,7 +6,7 @@ // never store message bodies, Room Brief bodies, participant bearer tokens, // tokenized invite URLs, or any message-derived content. See #80. -import type { ParticipantKind } from "../protocol/index.js"; +import type { ChannelType, ParticipantKind } from "../protocol/index.js"; /** * Central platform status for a room. This is the control-plane metadata status @@ -64,6 +64,19 @@ export interface RosterEntry { last_seen_at?: string; } +/** + * Sanitized channel metadata for the control plane. This is the *only* channel + * shape a hosted-room public response carries: exactly the channel id, display + * name, and type. The boardroom store's `lifecycle`, `createdAt`, and every + * other internal field are deliberately stripped, and no token, invite/card URL, + * cursor, or message content is ever derived from a channel here. + */ +export interface PublicChannel { + id: string; + name: string; + type: ChannelType; +} + /** Route reachability/health metadata. Carries no request or response content. */ export interface RouteHealth { reachable: boolean; diff --git a/test/platform-api.test.ts b/test/platform-api.test.ts index 84a4f53..4e64bf6 100644 --- a/test/platform-api.test.ts +++ b/test/platform-api.test.ts @@ -4,6 +4,26 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; import { createControlPlaneRoom, listRoomsResponse, readRoomResponse } from "../src/platform/index.js"; +import { createBoardroom, createRoom } from "../src/storage/index.js"; +import type { Channel } from "../src/protocol/index.js"; + +function channel(overrides: Partial & Pick): Channel { + return { + name: overrides.id, + type: "chat", + lifecycle: "active", + createdAt: "2026-01-01T00:00:00.000Z", + ...overrides + }; +} + +// Materialize a host boardroom store for a room. The room directory must exist +// first (the boardroom store writes under the room's writer lock), so create the +// host room before writing the boardroom. +async function seedBoardroom(root: string, roomId: string, channels: Channel[]): Promise { + await createRoom({ root, roomId, hostAlias: "host" }); + await createBoardroom(root, roomId, { channels }); +} async function makeRoot(): Promise { return mkdtemp(path.join(os.tmpdir(), "agentgather-platform-api-test-")); @@ -82,3 +102,65 @@ test("API responses never carry tokens, bearer headers, or message content", asy assert.doesNotMatch(serialized, /Bearer|tgl_|token_hash|"message"/); } }); + +test("hosted rooms carry sanitized channels reduced to exactly id, name, type", async () => { + const root = await makeRoot(); + await seed(root); + await seedBoardroom(root, "owned-room", [ + channel({ id: "general", name: "general", type: "chat" }), + channel({ id: "ideas", name: "Ideas", type: "forum", lifecycle: "idle" }) + ]); + const expected = [ + { id: "general", name: "general", type: "chat" }, + { id: "ideas", name: "Ideas", type: "forum" } + ]; + + const read = await readRoomResponse(root, "owned-room", { owner_user_id: "user-1" }); + const readBody = read.body as { room: { channels: unknown[] } }; + assert.deepEqual(readBody.room.channels, expected); + + const list = await listRoomsResponse(root, { owner_user_id: "user-1" }); + const listBody = list.body as { rooms: Array<{ room_id: string; channels: unknown[] }> }; + assert.deepEqual(listBody.rooms.find((room) => room.room_id === "owned-room")?.channels, expected); +}); + +test("removed channels are omitted from the public channel list", async () => { + const root = await makeRoot(); + await seed(root); + await seedBoardroom(root, "owned-room", [ + channel({ id: "general", name: "general", type: "chat" }), + channel({ id: "archived", name: "Archived", type: "forum", lifecycle: "removed" }) + ]); + const read = await readRoomResponse(root, "owned-room", { owner_user_id: "user-1" }); + const body = read.body as { room: { channels: Array<{ id: string }> } }; + assert.deepEqual(body.room.channels.map((c) => c.id), ["general"]); +}); + +test("a room without a boardroom store falls back to a single #general chat channel", async () => { + const root = await makeRoot(); + await seed(root); // no boardroom store is materialized for owned-room + const read = await readRoomResponse(root, "owned-room", { owner_user_id: "user-1" }); + const body = read.body as { room: { channels: unknown[] } }; + assert.deepEqual(body.room.channels, [{ id: "general", name: "general", type: "chat" }]); +}); + +test("channel metadata stays owner-scoped: a non-owner gets not_found and no channel leak", async () => { + const root = await makeRoot(); + await seed(root); + await seedBoardroom(root, "other-room", [channel({ id: "secret", name: "Secret Ops", type: "forum" })]); + const read = await readRoomResponse(root, "other-room", { owner_user_id: "user-1" }); + assert.equal(read.status, 404); + assert.doesNotMatch(JSON.stringify(read.body), /secret/i); + const list = await listRoomsResponse(root, { owner_user_id: "user-1" }); + assert.doesNotMatch(JSON.stringify(list.body), /secret/i); +}); + +test("channels are token-free: present but carrying no token, url, lifecycle, or cursor", async () => { + const root = await makeRoot(); + await seed(root); + await seedBoardroom(root, "owned-room", [channel({ id: "general", name: "general", type: "chat" })]); + const read = await readRoomResponse(root, "owned-room", { owner_user_id: "user-1" }); + const body = read.body as { room: { channels: unknown[] } }; + assert.ok(body.room.channels.length > 0); // non-vacuous: channels really are present + assert.doesNotMatch(JSON.stringify(read.body), /Bearer|tgl_|token_hash|invite|"lifecycle"|"createdAt"|cursor|Authorization/i); +}); diff --git a/test/platform-http.test.ts b/test/platform-http.test.ts index e60197c..ec398a7 100644 --- a/test/platform-http.test.ts +++ b/test/platform-http.test.ts @@ -8,7 +8,7 @@ import test from "node:test"; import { VERSION } from "../src/cli/help.js"; import { createPlatformHttpServer } from "../src/platform/index.js"; import { createControlPlaneRoom } from "../src/platform/index.js"; -import { appendServerMessage, createRoom, recordJoinedRoom } from "../src/storage/index.js"; +import { appendServerMessage, createBoardroom, createRoom, recordJoinedRoom } from "../src/storage/index.js"; import { createRoomHttpServer } from "../src/server/index.js"; function requestWithHost(baseUrl: string, hostHeader: string): Promise<{ status: number; body: string }> { @@ -121,6 +121,45 @@ test("lists only the owner's rooms and reads one room's metadata", async () => { } }); +test("hosted room responses expose sanitized channels over the HTTP surface", async () => { + const root = await makeRoot(); + await createControlPlaneRoom(root, roomInput({ room_id: "alpha" })); + await createControlPlaneRoom(root, roomInput({ room_id: "legacy" })); + await createRoom({ root, roomId: "alpha", hostAlias: "host" }); + await createBoardroom(root, "alpha", { + channels: [ + { id: "general", name: "general", type: "chat", lifecycle: "active", createdAt: "2026-01-01T00:00:00.000Z" }, + { id: "ideas", name: "Ideas", type: "forum", lifecycle: "active", createdAt: "2026-01-01T00:00:00.000Z" }, + { id: "old", name: "Old", type: "forum", lifecycle: "removed", createdAt: "2026-01-01T00:00:00.000Z" } + ] + }); + + const fixture = await startServer(root, "owner-1"); + try { + const alpha = await (await fetch(`${fixture.baseUrl}/rooms/alpha`)).json(); + assert.deepEqual(alpha.room.channels, [ + { id: "general", name: "general", type: "chat" }, + { id: "ideas", name: "Ideas", type: "forum" } + ]); + + // A room with no boardroom store falls back to a single #general chat channel. + const legacy = await (await fetch(`${fixture.baseUrl}/rooms/legacy`)).json(); + assert.deepEqual(legacy.room.channels, [{ id: "general", name: "general", type: "chat" }]); + + // The list surface carries the same sanitized channels, and the removed + // channel and its internal fields never cross the wire. + const listRaw = await (await fetch(`${fixture.baseUrl}/rooms`)).text(); + assert.doesNotMatch(listRaw, /"old"|"removed"|"lifecycle"|"createdAt"/); + const list = JSON.parse(listRaw) as { rooms: Array<{ room_id: string; channels: unknown[] }> }; + assert.deepEqual(list.rooms.find((room) => room.room_id === "alpha")?.channels, [ + { id: "general", name: "general", type: "chat" }, + { id: "ideas", name: "Ideas", type: "forum" } + ]); + } finally { + await fixture.close(); + } +}); + test("chat read surfaces the live host-owned message log for an owner's room", async () => { const root = await makeRoot(); await createControlPlaneRoom(root, roomInput({ room_id: "demo-room" }));