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

+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;
}
}