Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 36 additions & 3 deletions src/platform/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<PlatformApiResponse> {
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. */
Expand All @@ -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<PublicRoom> {
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 {
Expand Down
15 changes: 14 additions & 1 deletion src/platform/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
82 changes: 82 additions & 0 deletions test/platform-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Channel> & Pick<Channel, "id">): 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<void> {
await createRoom({ root, roomId, hostAlias: "host" });
await createBoardroom(root, roomId, { channels });
}

async function makeRoot(): Promise<string> {
return mkdtemp(path.join(os.tmpdir(), "agentgather-platform-api-test-"));
Expand Down Expand Up @@ -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);
});
41 changes: 40 additions & 1 deletion test/platform-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> {
Expand Down Expand Up @@ -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" }));
Expand Down
Loading