Hosted rooms: the hub's side
CI / test (push) Successful in 1m5s

Rooms made on the website (hosted_room, migration 0005), the REST routes in
docs/hosted-rooms.md, and the realtime room.* protocol: joining with the
capacity, adult and linked-account rules, seats, numbered messages with
forged senders dropped and a rate limit, closing. The Rooms page lists them,
with a form to make one and the owner's edit and close.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 03:21:21 -07:00
1 parent 0c67232425
commit 2a1fc94df6
18 files changed
+3138 -52

No files matched your search

+190 -4
View File
@@ -1,6 +1,6 @@
// The live connection with signed-in proxies: which IMVU account each client is using (presence), the community
// products it wears, and the IMVU chats it's in (the room channel: T5DE users in the same chat see each other's
// community products).
// products it wears, the IMVU chats it's in (the room channel: T5DE users in the same chat see each other's
// community products), and T5DE-hosted rooms (the hub is their chat server; docs/hosted-rooms.md).
//
// Protocol (JSON text messages over the WebSocket at /api/v1/realtime, authorized with the proxy's session token as
// a bearer token):
@@ -13,13 +13,40 @@
// the other T5DE users in that chat and what they wear; sent on join and on every change. Adult
// products only go to clients whose IMVU account counts as adult.
// hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."}
//
// Hosted rooms (n: the room's number; its chat ID is 2100000000 + n):
// proxy -> hub {"type": "room.join", "room": n} joining again (a reconnect) answers the same
// proxy -> hub {"type": "room.leave", "room": n}
// proxy -> hub {"type": "room.seat", "room": n, "seat": 3} 0-99
// proxy -> hub {"type": "room.send", "room": n, "mount": "messages" | "control", "message": "..."}
// to everyone else in the room (up to 64 KB); a "messages" JSON whose userId isn't the sender's IMVU
// account is dropped, and so is anything over the rate (bursts of 20, then 10 a second)
// hub -> proxy {"type": "room.joined", "room": n, "chat": chatId, "participants": [{"imvu_id", "seat"}],
// "sequence": s} (the latest message number)
// hub -> proxy {"type": "room.error", "room": n, "reason": "full" | "closed" | "adult" | "not_found" |
// "not_linked" | "too_many", "message": "..."}
// hub -> proxy {"type": "room.message", "room": n, "from": imvuId, "mount": "...", "message": "...", "sequence": s}
// hub -> proxy {"type": "room.participants", "room": n, "participants": [...]} on every join, leave or seat
// hub -> proxy {"type": "room.closed", "room": n} the room closed, or this session is out of it (its IMVU account
// was unlinked or changed, or the room became adult)
export type Item = { id: string; name: string; adult: boolean };
export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[]; rooms: string[] };
/** What realtime needs to know of a hosted room to let someone in. */
export type HostedRoomInfo = { closed: boolean; adult: boolean; maxUsers: number };
/** Hosted rooms' chat IDs and instance IDs start here (docs/hosted-rooms.md). */
export const hostedBase = 2_100_000_000;
/** The highest room number: chat IDs must stay XML-RPC integers. */
export const maxHostedN = 2_147_483_647 - hostedBase;
type Participant = { imvu_id: number; seat: number };
// requested: the IMVU account the client said it's using, even before it's linked (it counts once it is).
// chats: IMVU chat ID -> room instance ("" for chats that aren't a room's). lastSent: per chat, what it was last told.
// hosted: the hosted rooms it's in. tokens, refilled: its allowance of room messages.
type Session = {
userId: string;
imvuId: number;
@@ -29,10 +56,17 @@ type Session = {
outfit: Item[];
chats: Map<string, string>;
lastSent: Map<string, string>;
hosted: Set<number>;
tokens: number;
refilled: number;
send: (message: object) => void;
close: () => void;
};
// A hosted room in use: who's in it (session ID -> IMVU account and seat) and its latest message number. Kept while
// the hub runs, so the numbers keep counting up when a room empties and fills again; dropped when it closes.
type Hosted = { members: Map<string, { imvuId: number; seat: number }>; sequence: number };
export type RealtimeDeps = {
/** The person an IMVU account is linked to. */
owner: (imvuId: number) => Promise<string | undefined>;
@@ -40,20 +74,37 @@ export type RealtimeDeps = {
adult?: (userId: string, imvuId: number) => Promise<boolean>;
/** A client entered an IMVU room (to learn about rooms for the rooms list). */
room?: (instanceId: string) => void;
/** A hosted room (undefined: there's none by that number). */
hostedRoom?: (n: number) => Promise<HostedRoomInfo | undefined>;
};
const maxChats = 16;
const chatPattern = /^[0-9]{1,20}$/;
const roomPattern = /^[0-9]{1,12}-[0-9]{1,12}$/;
const maxHostedPerSession = 5;
const maxRoomMessage = 64 * 1024; // bytes, as UTF-8
const sendBurst = 20;
const sendPerSecond = 10;
const roomErrors = {
not_linked: "Sign in to T5DE with a linked IMVU account first.",
not_found: "No such room.",
closed: "This room is closed.",
adult: "This room is for adults.",
full: "This room is full.",
too_many: "You're in too many rooms.",
} as const;
export class Realtime {
private sessions = new Map<string, Session>();
private hosted = new Map<number, Hosted>();
private listeners = new Set<() => void>();
private readonly deps: Required<RealtimeDeps>;
constructor(deps: RealtimeDeps | RealtimeDeps["owner"]) {
const d = typeof deps === "function" ? { owner: deps } : deps;
this.deps = { adult: async () => false, room: () => {}, ...d };
this.deps = { adult: async () => false, room: () => {}, hostedRoom: async () => undefined, ...d };
}
/** Calls fn on every change of presence, outfits or rooms; returns a function that stops it. */
@@ -94,6 +145,9 @@ export class Realtime {
outfit: [],
chats: new Map(),
lastSent: new Map(),
hosted: new Set(),
tokens: sendBurst,
refilled: Date.now(),
send,
close,
});
@@ -101,6 +155,7 @@ export class Realtime {
close(id: string) {
const s = this.sessions.get(id);
if (s) this.leaveAllHosted(id, s, false);
this.sessions.delete(id);
if (s && (s.imvuId || s.chats.size)) this.changed(s.chats.keys());
}
@@ -125,8 +180,9 @@ export class Realtime {
unlinked(userId: string, imvuId: number) {
const chats: string[] = [];
let changed = false;
for (const s of this.sessions.values()) {
for (const [id, s] of this.sessions) {
if (s.userId === userId && s.imvuId === imvuId) {
this.leaveAllHosted(id, s, true);
s.imvuId = 0;
s.adult = false;
s.outfit = [];
@@ -171,6 +227,115 @@ export class Realtime {
}
}
// ---- Hosted rooms ----
/** How many people are in a hosted room now (IMVU accounts, each once). */
occupancy(n: number): number {
return new Set([...(this.hosted.get(n)?.members.values() ?? [])].map((m) => m.imvuId)).size;
}
/** A hosted room's participants: each IMVU account once, with the seat it last took. */
private participants(n: number): Participant[] {
const seats = new Map<number, number>();
for (const m of this.hosted.get(n)?.members.values() ?? []) seats.set(m.imvuId, m.seat);
return [...seats].map(([imvuId, seat]) => ({ imvu_id: imvuId, seat }));
}
private toRoom(n: number, message: object, except?: string) {
for (const id of this.hosted.get(n)?.members.keys() ?? []) if (id !== except) this.sessions.get(id)?.send(message);
}
private participantsChanged(n: number, except?: string) {
this.toRoom(n, { type: "room.participants", room: n, participants: this.participants(n) }, except);
}
/** Takes a session out of a hosted room; tell: say so (room.closed), when it didn't ask to leave. */
private leaveHosted(id: string, s: Session, n: number, tell: boolean) {
s.hosted.delete(n);
if (!this.hosted.get(n)?.members.delete(id)) return;
if (tell) s.send({ type: "room.closed", room: n });
this.participantsChanged(n);
}
private leaveAllHosted(id: string, s: Session, tell: boolean) {
for (const n of [...s.hosted]) this.leaveHosted(id, s, n, tell);
}
/** A hosted room closed: everyone in it is told, and its state is dropped. */
hostedClosed(n: number) {
const room = this.hosted.get(n);
if (!room) return;
this.hosted.delete(n);
for (const id of room.members.keys()) {
const s = this.sessions.get(id);
s?.hosted.delete(n);
s?.send({ type: "room.closed", room: n });
}
}
/** A hosted room's settings changed: if it became adult, those who aren't leave. (A lower maxUsers only stops
* more from coming in.) */
hostedChanged(n: number, info: HostedRoomInfo) {
if (info.closed) return this.hostedClosed(n);
if (!info.adult) return;
for (const id of [...(this.hosted.get(n)?.members.keys() ?? [])]) {
const s = this.sessions.get(id);
if (s && !s.adult) this.leaveHosted(id, s, n, true);
}
}
private async joinHosted(id: string, s: Session, n: number): Promise<object> {
const refuse = (reason: keyof typeof roomErrors) => ({ type: "room.error", room: n, reason, message: roomErrors[reason] });
if (!s.imvuId) return refuse("not_linked");
const imvuId = s.imvuId;
const info = await this.deps.hostedRoom(n);
if (!info) return refuse("not_found");
if (info.closed) return refuse("closed");
const adult = await this.deps.adult(s.userId, imvuId);
// The session may have closed or changed accounts while waiting.
if (!this.sessions.has(id) || s.imvuId !== imvuId) return refuse("not_linked");
s.adult = adult;
if (info.adult && !s.adult) return refuse("adult");
let room = this.hosted.get(n);
if (!room?.members.has(id)) {
if (s.hosted.size >= maxHostedPerSession) return refuse("too_many");
const here = new Set([...(room?.members.values() ?? [])].map((m) => m.imvuId));
if (!here.has(imvuId) && here.size >= info.maxUsers) return refuse("full");
if (!room) this.hosted.set(n, (room = { members: new Map(), sequence: 0 }));
room.members.set(id, { imvuId, seat: 0 });
s.hosted.add(n);
this.participantsChanged(n, id); // the newcomer's list comes in room.joined
}
return { type: "room.joined", room: n, chat: hostedBase + n, participants: this.participants(n), sequence: room.sequence };
}
/** Whether a session may send another room message now (a bucket of tokens, refilled over time). */
private allowSend(s: Session): boolean {
const now = Date.now();
s.tokens = Math.min(sendBurst, s.tokens + ((now - s.refilled) / 1000) * sendPerSecond);
s.refilled = now;
if (s.tokens < 1) return false;
s.tokens -= 1;
return true;
}
private sendHosted(id: string, s: Session, n: number, mount: unknown, message: unknown): object | undefined {
const room = this.hosted.get(n);
const me = room?.members.get(id);
if (!room || !me) return { type: "error", message: "not in that room" };
if (mount !== "messages" && mount !== "control") return { type: "error", message: "bad mount" };
if (typeof message !== "string" || Buffer.byteLength(message) > maxRoomMessage)
return { type: "error", message: "bad message" };
if (mount === "messages") {
// The client flags a message whose userId isn't its sender as forged; the hub doesn't pass those on at all.
const parsed = safeJSON(message) as { userId?: unknown } | null | undefined;
if (!parsed || typeof parsed !== "object" || String(parsed.userId) !== String(me.imvuId)) return;
}
if (!this.allowSend(s)) return;
room.sequence++;
this.toRoom(n, { type: "room.message", room: n, from: me.imvuId, mount, message, sequence: room.sequence }, id);
}
/** Handles a message from a proxy; returns the reply to send, if any. */
async message(id: string, raw: unknown): Promise<object | undefined> {
const s = this.sessions.get(id);
@@ -181,12 +346,17 @@ export class Realtime {
items?: unknown;
chat?: unknown;
room?: unknown;
seat?: unknown;
mount?: unknown;
message?: unknown;
};
if (!m || typeof m !== "object") return { type: "error", message: "not a message" };
if (m.type === "session") {
const imvuId = Number(m.imvu_id);
s.requested = Number.isSafeInteger(imvuId) && imvuId > 0 ? imvuId : 0;
const owner = s.requested ? await this.deps.owner(imvuId) : undefined;
// Hosted rooms were joined as the session's account: another account (or none) leaves them.
if (owner !== s.userId || imvuId !== s.imvuId) this.leaveAllHosted(id, s, true);
if (owner !== s.userId) {
// Not this person's account: no presence, no outfit for it.
s.imvuId = 0;
@@ -231,6 +401,22 @@ export class Realtime {
this.changed([chat]);
return;
}
if (m.type === "room.join" || m.type === "room.leave" || m.type === "room.seat" || m.type === "room.send") {
const n = m.room;
if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > maxHostedN) return { type: "error", message: "bad room" };
if (m.type === "room.join") return this.joinHosted(id, s, n);
if (m.type === "room.leave") return void this.leaveHosted(id, s, n, false);
if (m.type === "room.send") return this.sendHosted(id, s, n, m.mount, m.message);
const seat = m.seat;
if (typeof seat !== "number" || !Number.isInteger(seat) || seat < 0 || seat > 99) return { type: "error", message: "bad seat" };
const member = this.hosted.get(n)?.members.get(id);
if (!member) return { type: "error", message: "not in that room" };
if (member.seat !== seat) {
member.seat = seat;
this.participantsChanged(n);
}
return;
}
return { type: "error", message: "unknown message type" };
}
}