Decorations over IMVU rooms: the hub's side
CI / test (push) Successful in 1m13s

T5 products placed in ordinary IMVU rooms, kept per room instance
(room_decoration, migration 0006). GET /api/v1/decorations/:instance (and
?instances= for up to 20) gives what the viewer may see, with a hash;
PUT is for the room owner's T5DE person only, checks the products exist,
and tells every realtime session with a chat in that room ({"type":
"decor"}). Hosted rooms' IDs are refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 03:58:42 -07:00
1 parent 2a1fc94df6
commit f65f36c1ea
12 files changed
+2325 -6

No files matched your search

+51 -1
View File
@@ -12,6 +12,7 @@ import { isAdultRoom, Rooms } from "./rooms";
import { Signer } from "./keys";
import { ProductError, Products } from "./products";
import { chatId, HostedError, HostedRooms, instanceId, maxPlacement, maxSceneItems } from "./hosted";
import { DecorationError, Decorations, maxBatch, maxDecorationPlacement, maxDecorations } from "./decorations";
const { user, session, imvuLink, linkChallenge, audit } = schema;
@@ -80,6 +81,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
const products = new Products(db, signer, imvu);
const rooms = roomsService ?? new Rooms(db, imvu);
const hosted = new HostedRooms(db);
const decorations = new Decorations(db);
const realtime =
rt ??
new Realtime({
@@ -248,6 +250,22 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
t.String({ maxLength: maxPlacement }),
]);
/** Runs a decorations operation, turning its errors into Problems. */
const decorationCall = async <T>(f: () => Promise<T>): Promise<T> => {
try {
return await f();
} catch (e) {
if (e instanceof DecorationError) throw new Problem(e.status, e.slug, e.message);
throw e;
}
};
const decorationItem = t.Tuple([
t.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
t.String({ pattern: "^[a-z2-7]{10}$" }),
t.String({ maxLength: maxDecorationPlacement }),
]);
const productParam = t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }) });
const ownLink = async (p: Person, imvuId: number) => {
@@ -689,6 +707,38 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
{ signedIn: true, params: hostedParam },
)
// Decorations over IMVU rooms (docs/products.md, "Decorations").
.get(
"/decorations",
async ({ person, query }) => {
const instances = [...new Set((query.instances ?? "").split(",").filter(Boolean))];
if (instances.length > maxBatch) throw new Problem(400, "invalid", `Up to ${maxBatch} rooms at a time.`);
return { rooms: await decorationCall(async () => decorations.get(instances, await personAdult(person))) };
},
{ query: t.Object({ instances: t.Optional(t.String({ maxLength: 1024 })) }) },
)
.get(
"/decorations/:instance",
async ({ person, params }) =>
(await decorationCall(async () => decorations.get([params.instance], await personAdult(person))))[0]!,
{ params: t.Object({ instance: t.String({ maxLength: 32 }) }) },
)
.put(
"/decorations/:instance",
async ({ me, params, body }) => {
if (!limiter.allow(`decor:${me.id}`, 60, 60 * 1000))
throw new Problem(429, "rate_limited", "Too many changes. Try again in a moment.");
await decorationCall(() => decorations.set(me.id, params.instance, body.contents));
realtime.decorated(params.instance);
return (await decorations.get([params.instance], await personAdult(me)))[0]!;
},
{
signedIn: true,
params: t.Object({ instance: t.String({ maxLength: 32 }) }),
body: t.Object({ contents: t.Array(decorationItem, { maxItems: maxDecorations }) }),
},
)
// Catalogs: registering one, and the directory.
.post(
"/catalogs",
@@ -1139,7 +1189,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, hosted };
return { app, realtime, age, rooms, products, signer, hosted, decorations };
}
export type App = ReturnType<typeof createApp>["app"];
+107
View File
@@ -0,0 +1,107 @@
// Decorations over IMVU rooms (docs/products.md, "Decorations"): T5 products an IMVU room's owner placed in it,
// kept here because IMVU only keeps the room without them. The routes are in app.ts; the notice to proxies in the
// room is realtime.ts's decorated().
import { createHash } from "node:crypto";
import { and, eq, inArray, ne, sql, schema, type Database, type DecorationItem } from "@t5de/db";
import { hostedBase } from "./realtime";
const { roomDecoration, product, productRevision, imvuLink } = schema;
export const maxDecorations = 300;
export const maxDecorationPlacement = 200;
export const maxBatch = 20;
/** An IMVU room instance: "<owner's customer ID>-<n>". */
export const decorationInstance = /^[1-9][0-9]{0,11}-[1-9][0-9]{0,9}$/;
/** An error for the person; the routes turn it into a Problem. */
export class DecorationError extends Error {
constructor(
readonly status: number,
readonly slug: string,
message: string,
) {
super(message);
}
}
export const decorationHash = (contents: DecorationItem[]) =>
createHash("sha256").update(JSON.stringify(contents)).digest("hex");
/** The room's owner (its IMVU account), after checking the instance ID; hosted rooms are refused. */
function ownerOf(instance: string): number {
if (!decorationInstance.test(instance)) throw new DecorationError(400, "invalid", "That isn't an IMVU room.");
const owner = Number(instance.slice(0, instance.indexOf("-")));
if (owner >= hostedBase) throw new DecorationError(400, "hosted_room", "Hosted rooms keep their own scene.");
return owner;
}
export type DecorationView = { instance: string; contents: DecorationItem[]; hash: string };
export class Decorations {
constructor(private readonly db: Database) {}
/**
* Of these product IDs, those a viewer may see: registered (a revision exists), with a revision that isn't
* retracted, and general unless the viewer counts as adult.
*/
private async visible(ids: string[], adult: boolean): Promise<Set<string>> {
if (ids.length === 0) return new Set();
const rows = await this.db
.select({ id: product.id })
.from(product)
.where(
and(
inArray(product.id, ids),
ne(product.latest, 0),
adult ? undefined : eq(product.rating, "general"),
sql`exists (select 1 from ${productRevision} where ${productRevision.productId} = ${product.id} and not ${productRevision.retracted})`,
),
);
return new Set(rows.map((r) => r.id));
}
/** Rooms' decorations as a viewer sees them (in the order asked; rooms without any have an empty list). */
async get(instances: string[], adult: boolean): Promise<DecorationView[]> {
for (const i of instances) ownerOf(i);
const rows = instances.length
? await this.db.select().from(roomDecoration).where(inArray(roomDecoration.instanceId, instances))
: [];
const stored = new Map(rows.map((r) => [r.instanceId, r.contents]));
const ok = await this.visible([...new Set(rows.flatMap((r) => r.contents.map((it) => it[1])))], adult);
return instances.map((instance) => {
const contents = (stored.get(instance) ?? []).filter((it) => ok.has(it[1]));
return { instance, contents, hash: decorationHash(contents) };
});
}
/** Sets a room's decorations (an empty list removes them): only one of the room owner's T5DE people may. */
async set(personId: string, instance: string, contents: DecorationItem[]): Promise<void> {
const owner = ownerOf(instance);
const [link] = await this.db
.select()
.from(imvuLink)
.where(and(eq(imvuLink.imvuId, owner), eq(imvuLink.userId, personId)));
if (!link) throw new DecorationError(403, "forbidden", "Only the room's owner can decorate it.");
if (contents.length > maxDecorations)
throw new DecorationError(400, "invalid", `A room holds up to ${maxDecorations} decorations.`);
if (new Set(contents.map((it) => it[0])).size !== contents.length)
throw new DecorationError(400, "invalid", "Each furniture instance ID can be used once.");
const ids = [...new Set(contents.map((it) => it[1]))];
if (ids.length) {
const known = await this.db
.select({ id: product.id })
.from(product)
.where(and(inArray(product.id, ids), ne(product.latest, 0)));
if (known.length !== ids.length) throw new DecorationError(400, "unknown_product", "Some of those products don't exist.");
}
if (contents.length === 0) {
await this.db.delete(roomDecoration).where(eq(roomDecoration.instanceId, instance));
return;
}
await this.db
.insert(roomDecoration)
.values({ instanceId: instance, ownerImvuId: owner, contents })
.onConflictDoUpdate({ target: roomDecoration.instanceId, set: { contents, updatedAt: new Date() } });
}
}
+9
View File
@@ -12,6 +12,9 @@
// hub -> proxy {"type": "chat", "chat": "...", "avatars": [{"imvu_id", "items"}]}
// 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": "decor", "room": "1-2"}
// that IMVU room's decorations changed (GET /api/v1/decorations/1-2 again); sent to every session
// with a chat in that room
// hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."}
//
// Hosted rooms (n: the room's number; its chat ID is 2100000000 + n):
@@ -198,6 +201,12 @@ export class Realtime {
for (const s of this.sessions.values()) if (s.userId === userId) s.send(message);
}
/** An IMVU room's decorations changed: every session in one of its chats is told, so its proxy fetches them. */
decorated(instance: string) {
const message = { type: "decor", room: instance };
for (const s of this.sessions.values()) if ([...s.chats.values()].includes(instance)) s.send(message);
}
/** Closes a person's connections (banned, or signed out of their computers). */
disconnect(userId: string) {
for (const s of this.sessions.values()) if (s.userId === userId) s.close();