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

+125 -1
View File
@@ -11,6 +11,7 @@ import { Realtime } from "./realtime";
import { isAdultRoom, Rooms } from "./rooms";
import { Signer } from "./keys";
import { ProductError, Products } from "./products";
import { chatId, HostedError, HostedRooms, instanceId, maxPlacement, maxSceneItems } from "./hosted";
const { user, session, imvuLink, linkChallenge, audit } = schema;
@@ -78,6 +79,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
const signer = new Signer(db, apiURL ?? "http://localhost:4101");
const products = new Products(db, signer, imvu);
const rooms = roomsService ?? new Rooms(db, imvu);
const hosted = new HostedRooms(db);
const realtime =
rt ??
new Realtime({
@@ -89,6 +91,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
room: (instanceId) => {
rooms.seen(instanceId).catch((e) => console.error("rooms:", e));
},
hostedRoom: (n) => hosted.info(n),
});
const limiter = new Limiter();
@@ -210,6 +213,41 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
createdAt: c.createdAt.toISOString(),
});
/** Runs a hosted-room operation, turning its errors into Problems. */
const hostedCall = async <T>(f: () => Promise<T>): Promise<T> => {
try {
return await f();
} catch (e) {
if (e instanceof HostedError) throw new Problem(e.status, e.slug, e.message);
throw e;
}
};
const hostedView = ({ room: r, ownerName }: { room: typeof schema.hostedRoom.$inferSelect; ownerName: string }) => ({
n: r.n,
instanceId: instanceId(r.n),
chatId: chatId(r.n),
name: r.name,
description: r.description,
roomPid: r.roomPid,
rating: r.rating as "general" | "adult",
maxUsers: r.maxUsers,
ownerImvuId: r.ownerImvuId,
ownerName,
occupancy: realtime.occupancy(r.n),
createdAt: r.createdAt.toISOString(),
});
const hostedParam = t.Object({ n: t.Numeric({ minimum: 1, maximum: 47_483_647, multipleOf: 1 }) });
const rating = t.Union([t.Literal("general"), t.Literal("adult")]);
const roomPid = t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
const maxUsers = t.Integer({ minimum: 2, maximum: 30 });
const sceneItem = t.Tuple([
t.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
t.Union([t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), t.String({ pattern: "^[a-z2-7]{10}$" })]),
t.String({ maxLength: maxPlacement }),
]);
const productParam = t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }) });
const ownLink = async (p: Person, imvuId: number) => {
@@ -565,6 +603,92 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
return { rooms: out };
})
// T5DE-hosted rooms (docs/hosted-rooms.md).
.post(
"/hosted-rooms",
async ({ me, body, set }) => {
if (!limiter.allow(`hosted:${me.id}`, 20, 60 * 60 * 1000))
throw new Problem(429, "rate_limited", "Too many new rooms. Try again in a while.");
const r = await hostedCall(async () => hosted.create(me.id, await personAdult(me), body));
await audited(me.id, "hosted_room.create", String(r.room.n), { name: r.room.name, rating: r.room.rating });
set.status = 201;
return { room: hostedView(r) };
},
{
signedIn: true,
body: t.Object({
name: t.String({ maxLength: 64 }),
description: t.Optional(t.String({ maxLength: 512 })),
roomPid,
rating,
maxUsers: t.Optional(maxUsers),
imvuId: t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
}),
},
)
.get(
"/hosted-rooms",
async ({ person, query }) => {
const mine = query.mine === "1" || query.mine === "true";
if (mine && !person) throw new Problem(401, "signed_out", "Sign in first.");
const rows = await hosted.list({ adult: await personAdult(person), mine: mine ? person!.id : undefined });
return { rooms: rows.map(hostedView) };
},
{ query: t.Object({ mine: t.Optional(t.String({ maxLength: 8 })) }) },
)
.get(
"/hosted-rooms/:n",
async ({ person, params }) => {
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(person), person?.id));
return { room: hostedView(r), scene: { contents: r.room.scene, hash: r.room.sceneHash } };
},
{ params: hostedParam },
)
.patch(
"/hosted-rooms/:n",
async ({ me, params, body }) => {
const r = await hostedCall(async () => hosted.update(me.id, await personAdult(me), params.n, body));
realtime.hostedChanged(r.room.n, { closed: false, adult: r.room.rating === "adult", maxUsers: r.room.maxUsers });
await audited(me.id, "hosted_room.edit", String(r.room.n), { fields: Object.keys(body) });
return { room: hostedView(r) };
},
{
signedIn: true,
params: hostedParam,
body: t.Object({
name: t.Optional(t.String({ maxLength: 64 })),
description: t.Optional(t.String({ maxLength: 512 })),
rating: t.Optional(rating),
maxUsers: t.Optional(maxUsers),
roomPid: t.Optional(roomPid),
}),
},
)
.put(
"/hosted-rooms/:n/scene",
async ({ me, params, body }) => {
if (!limiter.allow(`scene:${me.id}`, 60, 60 * 1000))
throw new Problem(429, "rate_limited", "Too many changes. Try again in a moment.");
const r = await hostedCall(() => hosted.setScene(me.id, params.n, body.contents, body.roomPid));
return { room: hostedView(r), scene: { contents: r.room.scene, hash: r.room.sceneHash } };
},
{
signedIn: true,
params: hostedParam,
body: t.Object({ contents: t.Array(sceneItem, { maxItems: maxSceneItems }), roomPid: t.Optional(roomPid) }),
},
)
.delete(
"/hosted-rooms/:n",
async ({ me, params, set }) => {
const r = await hostedCall(() => hosted.close(me.id, isModerator(me), params.n));
realtime.hostedClosed(r.n);
await audited(me.id, "hosted_room.close", String(r.n), { name: r.name, ...(r.ownerId !== me.id ? { owner: r.ownerId } : {}) });
set.status = 204;
},
{ signedIn: true, params: hostedParam },
)
// Catalogs: registering one, and the directory.
.post(
"/catalogs",
@@ -1015,7 +1139,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
.get("/health", () => ({ ok: true }))
.use(api);
return { app, realtime, age, rooms, products, signer };
return { app, realtime, age, rooms, products, signer, hosted };
}
export type App = ReturnType<typeof createApp>["app"];
+191
View File
@@ -0,0 +1,191 @@
// T5DE-hosted rooms (docs/hosted-rooms.md): made on the website, joined through T5DE's proxy. The routes are in
// app.ts and who's in a room is in realtime.ts; this keeps the rooms themselves.
//
// A room is shown (listed, joinable) while its owner IMVU account is still linked to the person who made it and they
// aren't banned; unlinking hides it until it's linked again. Closing keeps the row, so a number is never reused.
import { createHash } from "node:crypto";
import { and, count, desc, eq, sql, schema, type Database, type SceneItem } from "@t5de/db";
import { hostedBase, maxHostedN, type HostedRoomInfo } from "./realtime";
const { hostedRoom, imvuLink, user } = schema;
export type HostedRoom = typeof hostedRoom.$inferSelect;
export type Rating = "general" | "adult";
/** An error for the person; the routes turn it into a Problem. */
export class HostedError extends Error {
constructor(
readonly status: number,
readonly slug: string,
message: string,
) {
super(message);
}
}
export const maxRoomsPerPerson = 10;
export const maxSceneItems = 500;
export const maxPlacement = 200;
/** The scene's hash, which the client caches scenes by: of the room shell and its contents. */
export const sceneHash = (roomPid: number, contents: SceneItem[]) =>
createHash("sha256").update(JSON.stringify([roomPid, contents])).digest("hex");
export const instanceId = (n: number) => `${hostedBase}-${n}`;
export const chatId = (n: number) => hostedBase + n;
// eslint-disable-next-line no-control-regex
const controlChars = /[\u0000-\u0008\u000b-\u001f\u007f]/g;
function cleanName(s: string): string {
const v = s.replace(controlChars, "").replace(/\s+/g, " ").trim();
if (!v || v.length > 64) throw new HostedError(400, "invalid", "A name is 1 to 64 characters.");
return v;
}
function cleanDescription(s: string): string {
const v = s.replace(/\r\n?/g, "\n").replace(controlChars, "").trim();
if (v.length > 512) throw new HostedError(400, "invalid", "A description is up to 512 characters.");
return v;
}
export type NewRoom = {
name: string;
description?: string;
roomPid: number;
rating: Rating;
maxUsers?: number;
imvuId: number;
};
export type RoomChanges = Partial<Pick<NewRoom, "name" | "description" | "roomPid" | "rating" | "maxUsers">>;
export class HostedRooms {
constructor(private readonly db: Database) {}
// Rooms that are shown: the owner account still linked to the owner, the owner not banned.
private shown() {
return this.db
.select({ room: hostedRoom, ownerName: imvuLink.username })
.from(hostedRoom)
.innerJoin(imvuLink, and(eq(imvuLink.imvuId, hostedRoom.ownerImvuId), eq(imvuLink.userId, hostedRoom.ownerId)))
.innerJoin(user, eq(user.id, hostedRoom.ownerId))
.$dynamic();
}
private visible = sql`coalesce(${user.banned}, false) = false`;
/** Open rooms, newest first: all those the viewer may see, or (mine) the viewer's own. */
async list({ adult, mine }: { adult: boolean; mine?: string }) {
const rows = await this.shown()
.where(
and(
this.visible,
eq(hostedRoom.closed, false),
mine ? eq(hostedRoom.ownerId, mine) : undefined,
adult || mine ? undefined : eq(hostedRoom.rating, "general"),
),
)
.orderBy(desc(hostedRoom.createdAt), desc(hostedRoom.n))
.limit(200);
return rows;
}
/** A room as shown to a viewer; closed rooms are 410, hidden and adult ones (for others) 404. */
async get(n: number, adult: boolean, viewerId?: string) {
if (!Number.isInteger(n) || n < 1 || n > maxHostedN) throw new HostedError(404, "not_found", "No such room.");
const [r] = await this.shown().where(and(this.visible, eq(hostedRoom.n, n)));
if (!r) throw new HostedError(404, "not_found", "No such room.");
if (r.room.closed) throw new HostedError(410, "closed", "This room is closed.");
if (r.room.rating === "adult" && !adult && r.room.ownerId !== viewerId)
throw new HostedError(404, "not_found", "No such room.");
return r;
}
/** What realtime needs to let someone in (undefined: no such room, or it's hidden). */
async info(n: number): Promise<HostedRoomInfo | undefined> {
const [r] = await this.shown().where(and(this.visible, eq(hostedRoom.n, n)));
if (!r) return undefined;
return { closed: r.room.closed, adult: r.room.rating === "adult", maxUsers: r.room.maxUsers };
}
async create(ownerId: string, adultPerson: boolean, body: NewRoom) {
const name = cleanName(body.name);
const description = cleanDescription(body.description ?? "");
if (body.rating === "adult" && !adultPerson)
throw new HostedError(403, "not_adult", "Adult rooms need an age-verified IMVU account.");
const [link] = await this.db
.select()
.from(imvuLink)
.where(and(eq(imvuLink.imvuId, body.imvuId), eq(imvuLink.userId, ownerId)));
if (!link) throw new HostedError(400, "not_linked", "That IMVU account isn't linked to you.");
return this.db.transaction(async (tx) => {
const [{ n } = { n: 0 }] = await tx
.select({ n: count() })
.from(hostedRoom)
.where(and(eq(hostedRoom.ownerId, ownerId), eq(hostedRoom.closed, false)));
if (n >= maxRoomsPerPerson)
throw new HostedError(403, "room_cap", `You can have up to ${maxRoomsPerPerson} rooms. Close one first.`);
const [r] = await tx
.insert(hostedRoom)
.values({
ownerId,
ownerImvuId: link.imvuId,
name,
description,
roomPid: body.roomPid,
rating: body.rating,
maxUsers: body.maxUsers ?? 10,
scene: [],
sceneHash: sceneHash(body.roomPid, []),
})
.returning();
return { room: r!, ownerName: link.username };
});
}
/** The owner's room, open (for changes). */
private async own(ownerId: string, n: number) {
const r = await this.get(n, true, ownerId);
if (r.room.ownerId !== ownerId) throw new HostedError(403, "forbidden", "Only the room's owner can do this.");
return r;
}
async update(ownerId: string, adultPerson: boolean, n: number, changes: RoomChanges) {
const { room: r, ownerName } = await this.own(ownerId, n);
if (changes.rating === "adult" && r.rating !== "adult" && !adultPerson)
throw new HostedError(403, "not_adult", "Adult rooms need an age-verified IMVU account.");
const roomPid = changes.roomPid ?? r.roomPid;
const [updated] = await this.db
.update(hostedRoom)
.set({
...(changes.name !== undefined ? { name: cleanName(changes.name) } : {}),
...(changes.description !== undefined ? { description: cleanDescription(changes.description) } : {}),
...(changes.rating !== undefined ? { rating: changes.rating } : {}),
...(changes.maxUsers !== undefined ? { maxUsers: changes.maxUsers } : {}),
...(roomPid !== r.roomPid ? { roomPid, sceneHash: sceneHash(roomPid, r.scene) } : {}),
})
.where(eq(hostedRoom.n, n))
.returning();
return { room: updated!, ownerName };
}
async setScene(ownerId: string, n: number, contents: SceneItem[], roomPid?: number) {
const { room: r, ownerName } = await this.own(ownerId, n);
const pid = roomPid ?? r.roomPid;
const [updated] = await this.db
.update(hostedRoom)
.set({ scene: contents, roomPid: pid, sceneHash: sceneHash(pid, contents) })
.where(eq(hostedRoom.n, n))
.returning();
return { room: updated!, ownerName };
}
/** Closes a room: by its owner, or (moderator) anyone's. */
async close(actorId: string, moderator: boolean, n: number) {
const { room: r } = await this.get(n, true, actorId);
if (r.ownerId !== actorId && !moderator) throw new HostedError(403, "forbidden", "Only the room's owner can do this.");
await this.db.update(hostedRoom).set({ closed: true }).where(eq(hostedRoom.n, n));
return r;
}
}
+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" };
}
}
+322
View File
@@ -0,0 +1,322 @@
// T5DE-hosted rooms (docs/hosted-rooms.md): the REST routes and the realtime room protocol, with fake proxies.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { openDatabase, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const accounts = new Map<number, ImvuUser>();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) });
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
type Person = { cookie: string; token: string };
async function call(p: Person | undefined, method: string, path: string, body?: unknown) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
return { status: res.status, json: text ? JSON.parse(text) : undefined };
}
/** Signs a person up with linked IMVU accounts (adult: age verified on IMVU). */
async function person(name: string, ...imvu: { id: number; ageVerified?: boolean }[]): Promise<Person> {
const res = await fetch(base + "/api/auth/sign-up/email", {
method: "POST",
headers: { Origin: webURL, "Content-Type": "application/json" },
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
});
const p = {
cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "),
token: res.headers.get("set-auth-token") ?? "",
};
expect(p.token).not.toBe("");
for (const [i, a] of imvu.entries()) {
const username = `${name}Av${i || ""}`;
accounts.set(a.id, { id: a.id, username, tagline: "", ageVerified: !!a.ageVerified, accessPass: false });
const started = await call(p, "POST", "/api/v1/account/links", { username });
accounts.get(a.id)!.tagline = started.json.challenge.code;
expect((await call(p, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200);
}
return p;
}
/** A proxy's live connection, signed in to the client as imvuId (0: none). */
async function proxy(p: Person, imvuId: number) {
const ws = new WebSocket(`${base.replace("http", "ws")}/api/v1/realtime`, { headers: { Authorization: `Bearer ${p.token}` } } as any);
const got: any[] = [];
ws.onmessage = (e) => got.push(JSON.parse(String(e.data)));
await new Promise((r) => (ws.onopen = r));
const send = (m: object) => ws.send(JSON.stringify(m));
if (imvuId) {
send({ type: "session", imvu_id: imvuId });
await Bun.sleep(30);
}
const last = (type: string, room?: number) => got.filter((m) => m.type === type && (room === undefined || m.room === room)).at(-1);
/** Sends a message and waits for the answer of that type for that room. */
const ask = async (m: { type: string; room: number }, answer: (m: any) => boolean) => {
const before = got.length;
send(m);
await waitFor(() => got.slice(before).some(answer));
return got.slice(before).find(answer);
};
const join = (room: number) =>
ask({ type: "room.join", room }, (m) => (m.type === "room.joined" || m.type === "room.error") && m.room === room);
return { ws, got, send, last, join };
}
async function waitFor(ok: () => boolean) {
for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(10);
expect(ok()).toBe(true);
}
const newRoom = (imvuId: number, over: Record<string, unknown> = {}) => ({
name: "Lounge",
description: "A quiet place.",
roomPid: 1234,
rating: "general",
maxUsers: 10,
imvuId,
...over,
});
test("rooms are made, listed, changed and closed by their owners", async () => {
const ada = await person("adam", { id: 301, ageVerified: true }, { id: 302 });
const ben = await person("benn", { id: 311 });
// Validation and permissions.
expect((await call(undefined, "POST", "/api/v1/hosted-rooms", newRoom(301))).status).toBe(401);
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { name: " " }))).json.error).toBe("invalid");
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { maxUsers: 31 }))).status).toBe(400);
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { maxUsers: 1 }))).status).toBe(400);
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { roomPid: 0 }))).status).toBe(400);
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { rating: "x" }))).status).toBe(400);
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { description: "x".repeat(513) }))).status).toBe(400);
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(311))).json.error).toBe("not_linked");
expect((await call(ben, "POST", "/api/v1/hosted-rooms", newRoom(311, { rating: "adult" }))).json.error).toBe("not_adult");
// Made: owned by the chosen account, shown with its IMVU name.
const made = await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(302, { name: " The Lounge ", maxUsers: undefined }));
expect(made.status).toBe(201);
const n = made.json.room.n;
expect(made.json.room).toMatchObject({
instanceId: `2100000000-${n}`,
chatId: 2_100_000_000 + n,
name: "The Lounge",
description: "A quiet place.",
roomPid: 1234,
rating: "general",
maxUsers: 10,
ownerImvuId: 302,
ownerName: "adamAv1",
occupancy: 0,
});
const adult = (await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { name: "Late", rating: "adult" }))).json.room;
expect(adult.rating).toBe("adult");
// Listed newest first; adult rooms only for adults.
const names = async (p?: Person, q = "") => ((await call(p, "GET", `/api/v1/hosted-rooms${q}`)).json.rooms as any[]).map((r) => r.name);
expect(await names(ada)).toEqual(["Late", "The Lounge"]);
expect(await names(ben)).toEqual(["The Lounge"]);
expect(await names()).toEqual(["The Lounge"]);
expect(await names(ben, "?mine=1")).toEqual([]);
expect(await names(ada, "?mine=1")).toEqual(["Late", "The Lounge"]);
expect((await call(undefined, "GET", "/api/v1/hosted-rooms?mine=1")).status).toBe(401);
// One room, with its scene.
const got = await call(ben, "GET", `/api/v1/hosted-rooms/${n}`);
expect(got.json.scene.contents).toEqual([]);
expect(got.json.scene.hash).toMatch(/^[0-9a-f]{64}$/);
expect((await call(ben, "GET", `/api/v1/hosted-rooms/${adult.n}`)).status).toBe(404);
expect((await call(ben, "GET", "/api/v1/hosted-rooms/999999")).status).toBe(404);
expect((await call(ben, "GET", "/api/v1/hosted-rooms/x")).status).toBe(400);
// Changes: owner only.
expect((await call(ben, "PATCH", `/api/v1/hosted-rooms/${n}`, { name: "Mine now" })).status).toBe(403);
expect((await call(ada, "PATCH", `/api/v1/hosted-rooms/${n}`, { maxUsers: 40 })).status).toBe(400);
const patched = await call(ada, "PATCH", `/api/v1/hosted-rooms/${n}`, { name: "Lounge 2", maxUsers: 4, description: "" });
expect(patched.json.room).toMatchObject({ name: "Lounge 2", maxUsers: 4, description: "", roomPid: 1234 });
// The scene: owner only, checked.
const scene = { contents: [[1, 5678, "1 0 0 0 0 0 0 1"], [2, "abcdefghij", "1 2 0 0"]] };
expect((await call(ben, "PUT", `/api/v1/hosted-rooms/${n}/scene`, scene)).status).toBe(403);
expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { contents: [[1, "Nope!", "x"]] })).status).toBe(400);
expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { contents: [[1, 2]] })).status).toBe(400);
expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { contents: [[1, 2, "x".repeat(201)]] })).status).toBe(400);
const tooMany = { contents: Array.from({ length: 501 }, (_, i) => [i, 2, ""]) };
expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, tooMany)).status).toBe(400);
const put = await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { ...scene, roomPid: 4321 });
expect(put.status).toBe(200);
expect(put.json.scene.contents).toEqual(scene.contents);
expect(put.json.scene.hash).not.toBe(got.json.scene.hash);
const after = await call(ben, "GET", `/api/v1/hosted-rooms/${n}`);
expect(after.json.scene).toEqual(put.json.scene);
expect(after.json.room.roomPid).toBe(4321);
// Closing: owner only; then it's gone from lists and answers 410.
expect((await call(ben, "DELETE", `/api/v1/hosted-rooms/${n}`)).status).toBe(403);
expect((await call(ada, "DELETE", `/api/v1/hosted-rooms/${n}`)).status).toBe(204);
expect((await call(ben, "GET", `/api/v1/hosted-rooms/${n}`)).json.error).toBe("closed");
expect(await names(ada)).toEqual(["Late"]);
expect((await call(ada, "PATCH", `/api/v1/hosted-rooms/${n}`, { name: "Back" })).status).toBe(410);
// Unlinking the owner account hides its rooms.
await call(ada, "DELETE", "/api/v1/account/links/301");
expect(await names(ada)).toEqual([]);
});
test("a person may own up to 10 open rooms", async () => {
const cy = await person("cyd", { id: 321 });
const ns: number[] = [];
for (let i = 0; i < 10; i++) {
const r = await call(cy, "POST", "/api/v1/hosted-rooms", newRoom(321, { name: `Room ${i}` }));
expect(r.status).toBe(201);
ns.push(r.json.room.n);
}
expect((await call(cy, "POST", "/api/v1/hosted-rooms", newRoom(321))).json.error).toBe("room_cap");
await call(cy, "DELETE", `/api/v1/hosted-rooms/${ns[0]}`);
expect((await call(cy, "POST", "/api/v1/hosted-rooms", newRoom(321))).status).toBe(201);
});
test("joining, seats and messages in a hosted room", async () => {
const dee = await person("deen", { id: 401, ageVerified: true });
const eli = await person("elia", { id: 402 });
const fay = await person("faye", { id: 403 });
const room = (await call(dee, "POST", "/api/v1/hosted-rooms", newRoom(401, { maxUsers: 2 }))).json.room;
const n: number = room.n;
const adultRoom = (await call(dee, "POST", "/api/v1/hosted-rooms", newRoom(401, { rating: "adult" }))).json.room.n;
const d = await proxy(dee, 401);
const e = await proxy(eli, 402);
const f = await proxy(fay, 403);
// Refusals: no linked account in use, no such room, adult room for a non-adult.
const nobody = await proxy(eli, 0);
expect((await nobody.join(n)).reason).toBe("not_linked");
nobody.ws.close();
const notMine = await proxy(eli, 403); // Fay's account, not Eli's
expect((await notMine.join(n)).reason).toBe("not_linked");
notMine.ws.close();
expect((await e.join(999_999)).reason).toBe("not_found");
expect((await e.join(adultRoom)).reason).toBe("adult");
e.send({ type: "room.join", room: 0 });
await waitFor(() => e.got.some((m) => m.message === "bad room"));
// Dee and Eli join; each sees the other.
const dj = await d.join(n);
expect(dj).toEqual({ type: "room.joined", room: n, chat: 2_100_000_000 + n, participants: [{ imvu_id: 401, seat: 0 }], sequence: 0 });
const ej = await e.join(n);
expect(ej.participants).toEqual([{ imvu_id: 401, seat: 0 }, { imvu_id: 402, seat: 0 }]);
await waitFor(() => d.last("room.participants", n)?.participants.length === 2);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.occupancy).toBe(2);
// Full at two people; joining again (a reconnect) answers the same.
expect((await f.join(n)).reason).toBe("full");
expect((await e.join(n)).type).toBe("room.joined");
// Seats.
e.send({ type: "room.seat", room: n, seat: 3 });
await waitFor(() => d.last("room.participants", n)?.participants.some((p: any) => p.imvu_id === 402 && p.seat === 3));
e.send({ type: "room.seat", room: n, seat: 100 });
await waitFor(() => e.got.some((m) => m.message === "bad seat"));
// Messages go to the others, numbered; forged senders are dropped.
const chat = (userId: number, message: string) => JSON.stringify({ userId, chatId: 2_100_000_000 + n, message, to: 0 });
e.send({ type: "room.send", room: n, mount: "messages", message: chat(402, "hi") });
e.send({ type: "room.send", room: n, mount: "messages", message: chat(401, "I'm Dee") });
e.send({ type: "room.send", room: n, mount: "messages", message: "not JSON" });
e.send({ type: "room.send", room: n, mount: "control", message: JSON.stringify({ command: "PlayAction" }) });
await waitFor(() => d.got.filter((m) => m.type === "room.message").length === 2);
await Bun.sleep(50);
const msgs = d.got.filter((m) => m.type === "room.message");
expect(msgs).toEqual([
{ type: "room.message", room: n, from: 402, mount: "messages", message: chat(402, "hi"), sequence: 1 },
{ type: "room.message", room: n, from: 402, mount: "control", message: JSON.stringify({ command: "PlayAction" }), sequence: 2 },
]);
expect(e.got.filter((m) => m.type === "room.message")).toEqual([]); // not back to the sender
e.send({ type: "room.send", room: n, mount: "other", message: "x" });
await waitFor(() => e.got.some((m) => m.message === "bad mount"));
e.send({ type: "room.send", room: n, mount: "control", message: "x".repeat(64 * 1024 + 1) });
await waitFor(() => e.got.some((m) => m.message === "bad message"));
f.send({ type: "room.send", room: n, mount: "control", message: "{}" });
await waitFor(() => f.got.some((m) => m.message === "not in that room"));
// Rate: a burst of 20, then the rest is dropped.
for (let i = 0; i < 30; i++) e.send({ type: "room.send", room: n, mount: "control", message: `{"i":${i}}` });
await Bun.sleep(150);
const burst = d.got.filter((m) => m.type === "room.message").length - 2;
expect(burst).toBeGreaterThanOrEqual(18);
expect(burst).toBeLessThanOrEqual(23);
expect((await d.join(n)).sequence).toBe(2 + burst);
// Leaving makes room; a closed connection leaves too.
e.send({ type: "room.leave", room: n });
await waitFor(() => d.last("room.participants", n)?.participants.length === 1);
expect((await f.join(n)).type).toBe("room.joined");
f.ws.close();
await waitFor(() => d.last("room.participants", n)?.participants.length === 1);
// Closing tells everyone in it; joining it again is refused.
expect((await e.join(n)).type).toBe("room.joined");
expect((await call(dee, "DELETE", `/api/v1/hosted-rooms/${n}`)).status).toBe(204);
await waitFor(() => e.last("room.closed", n) !== undefined && d.last("room.closed", n) !== undefined);
expect((await e.join(n)).reason).toBe("closed");
d.ws.close();
e.ws.close();
});
test("a room made adult sends non-adults out; an unlinked account leaves its rooms", async () => {
const gil = await person("gilt", { id: 501, ageVerified: true });
const hal = await person("hale", { id: 502 });
const n = (await call(gil, "POST", "/api/v1/hosted-rooms", newRoom(501))).json.room.n;
const g = await proxy(gil, 501);
const h = await proxy(hal, 502);
await g.join(n);
await h.join(n);
expect((await call(gil, "PATCH", `/api/v1/hosted-rooms/${n}`, { rating: "adult" })).status).toBe(200);
await waitFor(() => h.last("room.closed", n) !== undefined);
await waitFor(() => g.last("room.participants", n)?.participants.length === 1);
expect(g.last("room.closed", n)).toBeUndefined();
await call(gil, "PATCH", `/api/v1/hosted-rooms/${n}`, { rating: "general" });
await h.join(n);
await waitFor(() => g.last("room.participants", n)?.participants.length === 2);
await call(hal, "DELETE", "/api/v1/account/links/502");
await waitFor(() => h.got.filter((m) => m.type === "room.closed").length === 2);
await waitFor(() => g.last("room.participants", n)?.participants.length === 1);
g.ws.close();
h.ws.close();
});