Hosted rooms phase 2: moderators, bans, privacy, invites, images, favorites, ratings, recent rooms, occupancy watch
CI / test (push) Successful in 1m10s

Moderators (up to 20 IMVU accounts, set by ID or name) and the owner account boot
and ban over realtime (room.boot); bans are listed and lifted over REST. Private
rooms are unlisted and joined by the owner, moderators and holders of an accepted
invite (room.invite / room.invited / room.invite_reply / room.invite_answer).
Room images (PNG/JPEG by magic bytes, up to 512 KB) are stored in the database
and served with cache headers. Per-person favorites, ratings and recent rooms.
room.watch pushes room.occupancy; participants carry their IMVU name, and
GET .../participants lists them. Contract in docs/hosted-rooms.md, "Phase 2".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 06:07:41 -07:00
1 parent 43183ec7ee
commit 76c8e0342e
16 files changed
+3851 -73

No files matched your search

+294 -14
View File
@@ -2,7 +2,21 @@
// origin (Next.js rewrites /api to here), so session cookies are first-party; T5DE's proxy calls it with a bearer
// token.
import { Elysia, t } from "elysia";
import { and, desc, eq, ilike, inArray, ne, or, sql, count, schema, type Database, type ParentInfo } from "@t5de/db";
import {
and,
desc,
eq,
ilike,
inArray,
ne,
or,
sql,
count,
schema,
type Database,
type ParentInfo,
type RoomModerator,
} from "@t5de/db";
import type { Auth } from "./auth";
import { AgeError, AgeService } from "./age";
import { imvuAgeProvider, imvuResult } from "./age/imvu";
@@ -11,7 +25,19 @@ 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";
import {
chatId,
HostedError,
HostedRooms,
instanceId,
maxImageBytes,
maxModerators,
maxPlacement,
maxSceneItems,
roomInfo,
type RoomChanges,
type Viewer,
} from "./hosted";
import { DecorationError, Decorations, maxBatch, maxDecorationPlacement, maxDecorations } from "./decorations";
const { user, session, imvuLink, linkChallenge, audit } = schema;
@@ -82,6 +108,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
const rooms = roomsService ?? new Rooms(db, imvu);
const hosted = new HostedRooms(db);
const decorations = new Decorations(db);
const apiBase = (apiURL ?? "http://localhost:4101").replace(/\/+$/, "");
const realtime =
rt ??
new Realtime({
@@ -94,6 +121,22 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
rooms.seen(instanceId).catch((e) => console.error("rooms:", e));
},
hostedRoom: (n) => hosted.info(n),
hostedBanned: (n, imvuId) => hosted.banned(n, imvuId),
hostedBoot: async (b) => {
if (b.ban) await hosted.ban(b.room, b.imvuId, b.by, b.minutes);
await audited(b.byUserId, b.ban ? "hosted_room.ban" : "hosted_room.boot", String(b.room), {
imvu: b.imvuId,
by: b.by,
...(b.ban ? { minutes: b.minutes } : {}),
});
},
accountName: async (imvuId) => {
const [l] = await db.select({ username: imvuLink.username }).from(imvuLink).where(eq(imvuLink.imvuId, imvuId));
return l?.username ?? "";
},
hostedVisit: (userId, n, imvuId) => {
hosted.visit(userId, n, imvuId).catch((e) => console.error("hosted visit:", e));
},
});
const limiter = new Limiter();
@@ -225,7 +268,10 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
}
};
const hostedView = ({ room: r, ownerName }: { room: typeof schema.hostedRoom.$inferSelect; ownerName: string }) => ({
type HostedRow = { room: typeof schema.hostedRoom.$inferSelect; ownerName: string };
type Extras = { favorite: boolean; score: number; ratings: number };
const hostedView = ({ room: r, ownerName }: HostedRow, extras: Extras = { favorite: false, score: 0, ratings: 0 }) => ({
n: r.n,
instanceId: instanceId(r.ownerImvuId, r.n),
chatId: chatId(r.n),
@@ -237,11 +283,77 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
ownerImvuId: r.ownerImvuId,
ownerName,
occupancy: realtime.occupancy(r.n),
privacy: (r.privacy === "private" ? "private" : "public") as "public" | "private",
moderators: r.moderators.map((m) => m.imvuId),
moderatorNames: r.moderators.map((m) => m.username),
image: r.imageHash ? `${apiBase}/api/v1/hosted-rooms/${r.n}/image?v=${r.imageHash.slice(0, 16)}` : null,
favorite: extras.favorite,
score: extras.score,
ratings: extras.ratings,
createdAt: r.createdAt.toISOString(),
});
/** Rooms as a viewer sees them: with their ratings, and (signed in) whether each is a favorite. */
const hostedViews = async (rows: HostedRow[], person: Person | undefined) => {
const ns = rows.map((r) => r.room.n);
const [scores, favorites] = await Promise.all([
hosted.scores(ns),
person ? hosted.favoriteSet(person.id, ns) : Promise.resolve(new Set<number>()),
]);
return rows.map((r) =>
hostedView(r, { favorite: favorites.has(r.room.n), ...(scores.get(r.room.n) ?? { score: 0, ratings: 0 }) }),
);
};
const hostedViewFor = async (r: HostedRow, person: Person | undefined) => (await hostedViews([r], person))[0]!;
/** The person and their linked IMVU accounts. */
const viewerOf = async (p: Person): Promise<Viewer> => ({
id: p.id,
imvuIds: (await db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.userId, p.id))).map((l) => l.id),
});
/** Moderators as the owner gave them (IMVU IDs, or names, which are looked up: linked accounts first, then IMVU). */
const resolveModerators = async (me: Person, entries: (number | string)[], known: RoomModerator[]) => {
if (entries.length > maxModerators) throw new Problem(400, "invalid", `A room has up to ${maxModerators} moderators.`);
const out: RoomModerator[] = [];
for (const e of entries) {
const text = typeof e === "string" ? e.trim() : "";
const id = typeof e === "number" ? e : /^[0-9]{1,15}$/.test(text) ? Number(text) : 0;
if (id > 0) {
const name = known.find((m) => m.imvuId === id)?.username;
const [l] = name ? [] : await db.select({ username: imvuLink.username }).from(imvuLink).where(eq(imvuLink.imvuId, id));
out.push({ imvuId: id, username: name ?? l?.username ?? "" });
continue;
}
if (!text || text.length > 64) throw new Problem(400, "invalid", "Moderators are IMVU names or account IDs.");
const k = known.find((m) => m.username.toLowerCase() === text.toLowerCase());
if (k) {
out.push(k);
continue;
}
const [l] = await db
.select({ imvuId: imvuLink.imvuId, username: imvuLink.username })
.from(imvuLink)
.where(sql`lower(${imvuLink.username}) = lower(${text})`);
if (l) {
out.push(l);
continue;
}
try {
const found = await imvuCall(me, () => imvu.byUsername(text));
out.push({ imvuId: found.id, username: found.username });
} catch (err) {
if (err instanceof Problem && err.slug === "no_such_imvu_account")
throw new Problem(404, "no_such_imvu_account", `IMVU has no account named ${text}.`);
throw err;
}
}
return out;
};
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 privacy = t.Union([t.Literal("public"), t.Literal("private")]);
const roomPid = t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
const maxUsers = t.Integer({ minimum: 2, maximum: 30 });
const sceneItem = t.Tuple([
@@ -628,7 +740,11 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
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 });
await audited(me.id, "hosted_room.create", String(r.room.n), {
name: r.room.name,
rating: r.room.rating,
privacy: r.room.privacy,
});
set.status = 201;
return { room: hostedView(r) };
},
@@ -641,34 +757,64 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
rating,
maxUsers: t.Optional(maxUsers),
imvuId: t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
privacy: t.Optional(privacy),
}),
},
)
.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) };
const on = (v?: string) => v === "1" || v === "true";
const mine = on(query.mine);
const favorites = on(query.favorites);
const recent = on(query.recent);
if ((mine || favorites || recent) && !person) throw new Problem(401, "signed_out", "Sign in first.");
const rows = await hosted.list({
adult: await personAdult(person),
viewer: person ? await viewerOf(person) : undefined,
mine,
favorites,
recent,
});
return { rooms: await hostedViews(rows, person) };
},
{
query: t.Object({
mine: t.Optional(t.String({ maxLength: 8 })),
favorites: t.Optional(t.String({ maxLength: 8 })),
recent: t.Optional(t.String({ maxLength: 8 })),
}),
},
{ 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 } };
return {
room: await hostedViewFor(r, person),
scene: { contents: r.room.scene, hash: r.room.sceneHash },
myRating: person ? await hosted.myRating(person.id, r.room.n) : 0,
};
},
{ 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) };
const { moderators: given, ...rest } = body;
const changes: RoomChanges = given
? {
...rest,
moderators: await resolveModerators(me, given, (await hostedCall(() => hosted.own(me.id, params.n))).room.moderators),
}
: rest;
const r = await hostedCall(async () => hosted.update(me.id, await personAdult(me), params.n, changes));
realtime.hostedChanged(r.room.n, roomInfo(r));
await audited(me.id, "hosted_room.edit", String(r.room.n), {
fields: Object.keys(body),
...(changes.moderators ? { moderators: changes.moderators.map((m) => m.imvuId) } : {}),
});
return { room: await hostedViewFor(r, me) };
},
{
signedIn: true,
@@ -679,6 +825,12 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
rating: t.Optional(rating),
maxUsers: t.Optional(maxUsers),
roomPid: t.Optional(roomPid),
privacy: t.Optional(privacy),
moderators: t.Optional(
t.Array(t.Union([t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), t.String({ maxLength: 64 })]), {
maxItems: maxModerators,
}),
),
}),
},
)
@@ -707,6 +859,134 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
{ signedIn: true, params: hostedParam },
)
// Phase 2: bans, images, favorites, ratings.
.get(
"/hosted-rooms/:n/participants",
async ({ person, params }) => {
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(person), person?.id));
return { participants: realtime.participants(r.room.n) };
},
{ params: hostedParam },
)
.get(
"/hosted-rooms/:n/bans",
async ({ me, params }) => {
await hostedCall(async () => hosted.managed(await viewerOf(me), params.n));
const bans = await hosted.bans(params.n);
return {
bans: bans.map((b) => ({
imvuId: b.imvuId,
byImvuId: b.byImvuId,
until: b.until?.toISOString() ?? null,
createdAt: b.createdAt.toISOString(),
})),
};
},
{ signedIn: true, params: hostedParam },
)
.delete(
"/hosted-rooms/:n/bans/:imvu",
async ({ me, params, set }) => {
if (!(await hostedCall(() => hosted.unban(me.id, params.n, params.imvu))))
throw new Problem(404, "not_found", "That account isn't banned here.");
await audited(me.id, "hosted_room.unban", String(params.n), { imvu: params.imvu });
set.status = 204;
},
{
signedIn: true,
params: t.Object({
n: t.Numeric({ minimum: 1, maximum: 47_483_647, multipleOf: 1 }),
imvu: t.Numeric({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, multipleOf: 1 }),
}),
},
)
.get(
"/hosted-rooms/:n/image",
async ({ person, params, request }) => {
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(person), person?.id));
const img = r.room.imageHash ? await hosted.image(params.n) : undefined;
if (!img) throw new Problem(404, "not_found", "This room has no image.");
const etag = `"${img.hash}"`;
const headers = {
"Cache-Control": `${r.room.rating === "adult" ? "private" : "public"}, max-age=3600`,
ETag: etag,
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
};
if (request.headers.get("if-none-match") === etag) return new Response(null, { status: 304, headers });
return new Response(new Uint8Array(img.data), { headers: { ...headers, "Content-Type": img.contentType } });
},
{ params: hostedParam },
)
.put(
"/hosted-rooms/:n/image",
async ({ me, params, body }) => {
if (!limiter.allow(`image:${me.id}`, 30, 60 * 60 * 1000))
throw new Problem(429, "rate_limited", "Too many uploads. Try again in a while.");
if (body.image.size > maxImageBytes) throw new Problem(400, "invalid", "An image is up to 512 KB.");
const data = Buffer.from(await body.image.arrayBuffer());
const r = await hostedCall(() => hosted.setImage(me.id, params.n, data));
await audited(me.id, "hosted_room.image", String(params.n), { bytes: data.length });
return { room: await hostedViewFor(r, me) };
},
{
signedIn: true,
params: hostedParam,
body: t.Object({ image: t.File() }),
beforeHandle({ request }) {
// Refuse big uploads before reading them (with room for the multipart framing).
if (Number(request.headers.get("content-length") ?? 0) > maxImageBytes + 16 * 1024)
throw new Problem(400, "invalid", "An image is up to 512 KB.");
},
},
)
.delete(
"/hosted-rooms/:n/image",
async ({ me, params }) => {
const r = await hostedCall(() => hosted.removeImage(me.id, params.n));
await audited(me.id, "hosted_room.image", String(params.n), { removed: true });
return { room: await hostedViewFor(r, me) };
},
{ signedIn: true, params: hostedParam },
)
.put(
"/hosted-rooms/:n/favorite",
async ({ me, params, set }) => {
if (!limiter.allow(`fav:${me.id}`, 120, 60 * 1000)) throw new Problem(429, "rate_limited", "Slow down a little.");
await hostedCall(async () => hosted.get(params.n, await personAdult(me), me.id));
await hosted.favorite(me.id, params.n, true);
set.status = 204;
},
{ signedIn: true, params: hostedParam },
)
.delete(
"/hosted-rooms/:n/favorite",
async ({ me, params, set }) => {
await hosted.favorite(me.id, params.n, false);
set.status = 204;
},
{ signedIn: true, params: hostedParam },
)
.put(
"/hosted-rooms/:n/rating",
async ({ me, params, body }) => {
if (!limiter.allow(`rate:${me.id}`, 120, 60 * 1000)) throw new Problem(429, "rate_limited", "Slow down a little.");
const r = await hostedCall(async () => hosted.get(params.n, await personAdult(me), me.id));
if (r.room.ownerId === me.id) throw new Problem(403, "forbidden", "You can't rate your own room.");
await hosted.rate(me.id, params.n, body.score);
return { room: await hostedViewFor(r, me), myRating: body.score };
},
{ signedIn: true, params: hostedParam, body: t.Object({ score: t.Integer({ minimum: 1, maximum: 5 }) }) },
)
.delete(
"/hosted-rooms/:n/rating",
async ({ me, params, set }) => {
await hosted.rate(me.id, params.n, null);
set.status = 204;
},
{ signedIn: true, params: hostedParam },
)
// Decorations over IMVU rooms (docs/products.md, "Decorations").
.get(
"/decorations",