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:
1 parent
2a1fc94df6
commit
f65f36c1ea
12 files changed
+2325
-6
No files matched your search
+51
-1
@@ -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"];
|
||||
@@ -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() } });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// Decorations over IMVU rooms (docs/products.md, "Decorations"): the REST routes and the realtime notice, with fake
|
||||
// proxies.
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { createHash } from "node:crypto";
|
||||
import { eq, openDatabase, schema, 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; id: 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") ?? "",
|
||||
id: "",
|
||||
};
|
||||
expect(p.token).not.toBe("");
|
||||
p.id = (await call(p, "GET", "/api/v1/me")).json.person.id;
|
||||
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 registered product with one revision (retracted: that revision is). */
|
||||
async function product(id: string, creatorId: string, rating: "general" | "adult", retracted = false) {
|
||||
await db.insert(schema.product).values({ id, creatorId, name: id, rating, latest: 1 });
|
||||
await db.insert(schema.productRevision).values({ productId: id, number: 1, files: [], rating, token: "t", retracted });
|
||||
}
|
||||
|
||||
/** A proxy's live connection, signed in to the client as imvuId. */
|
||||
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));
|
||||
send({ type: "session", imvu_id: imvuId });
|
||||
await Bun.sleep(30);
|
||||
return { ws, got, send, decor: () => got.filter((m) => m.type === "decor") };
|
||||
}
|
||||
|
||||
async function waitFor(ok: () => boolean) {
|
||||
for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(10);
|
||||
expect(ok()).toBe(true);
|
||||
}
|
||||
|
||||
const hash = (v: unknown) => createHash("sha256").update(JSON.stringify(v)).digest("hex");
|
||||
|
||||
test("only the room's owner decorates it; contents are checked", async () => {
|
||||
const owner = await person("dora", { id: 501 }, { id: 502 });
|
||||
const other = await person("dave", { id: 511 });
|
||||
const unlinked = await person("dina");
|
||||
await product("aaaaaaaaaa", owner.id, "general");
|
||||
const room = "502-3";
|
||||
const item = [7, "aaaaaaaaaa", "0 0 0"];
|
||||
|
||||
expect((await call(undefined, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(401);
|
||||
expect((await call(other, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(403);
|
||||
expect((await call(unlinked, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(403);
|
||||
|
||||
// Validation.
|
||||
const put = (contents: unknown, instance = room) => call(owner, "PUT", `/api/v1/decorations/${instance}`, { contents });
|
||||
for (const bad of ["502", "0502-3", "502-0", "502-3-4", "x-1", "1234567890123-1"])
|
||||
expect((await put([item], bad)).status).toBe(400);
|
||||
expect((await put([item], "2100000000-5")).json.error).toBe("hosted_room");
|
||||
expect((await put([[7, "AAAAAAAAAA", ""]])).status).toBe(400);
|
||||
expect((await put([[7, 12345, ""]])).status).toBe(400);
|
||||
expect((await put([[-1, "aaaaaaaaaa", ""]])).status).toBe(400);
|
||||
expect((await put([[7, "aaaaaaaaaa", "x".repeat(201)]])).status).toBe(400);
|
||||
expect((await put([[7, "aaaaaaaaaa"]])).status).toBe(400);
|
||||
expect((await put([item, item])).status).toBe(400);
|
||||
expect((await put(Array.from({ length: 301 }, (_, i) => [i, "aaaaaaaaaa", ""]))).status).toBe(400);
|
||||
const unknown = await put([item, [8, "bbbbbbbbbb", ""]]);
|
||||
expect(unknown.status).toBe(400);
|
||||
expect(unknown.json.error).toBe("unknown_product");
|
||||
|
||||
// The owner's other account is fine too; up to 300 items.
|
||||
const ok = await put(Array.from({ length: 300 }, (_, i) => [i, "aaaaaaaaaa", "0 0 0"]));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.json.contents).toHaveLength(300);
|
||||
const set = await put([item]);
|
||||
expect(set.json).toEqual({ instance: room, contents: [item], hash: hash([item]) });
|
||||
expect((await call(undefined, "GET", `/api/v1/decorations/${room}`)).json).toEqual(set.json);
|
||||
// Not another person's room of the same number.
|
||||
expect((await call(owner, "PUT", "/api/v1/decorations/511-3", { contents: [item] })).status).toBe(403);
|
||||
});
|
||||
|
||||
test("viewers see the products they may, with a hash of what they see", async () => {
|
||||
const owner = await person("eli", { id: 601, ageVerified: true });
|
||||
const kid = await person("emma", { id: 611 });
|
||||
const grown = await person("evan", { id: 621, ageVerified: true });
|
||||
await product("cccccccccc", owner.id, "general");
|
||||
await product("dddddddddd", owner.id, "adult");
|
||||
await product("eeeeeeeeee", owner.id, "general", true); // its only revision is retracted
|
||||
const room = "601-1";
|
||||
const g = [1, "cccccccccc", "a"];
|
||||
const a = [2, "dddddddddd", "b"];
|
||||
const r = [3, "eeeeeeeeee", "c"];
|
||||
|
||||
// Unknown rooms have nothing.
|
||||
expect((await call(undefined, "GET", "/api/v1/decorations/601-9")).json).toEqual({ instance: "601-9", contents: [], hash: hash([]) });
|
||||
expect((await call(undefined, "GET", "/api/v1/decorations/601-x")).status).toBe(400);
|
||||
expect((await call(undefined, "GET", "/api/v1/decorations/2100000000-1")).status).toBe(400);
|
||||
|
||||
const put = await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [g, a, r] });
|
||||
expect(put.status).toBe(200);
|
||||
expect(put.json.contents).toEqual([g, a]); // the owner is adult; the retracted one is left out
|
||||
|
||||
const view = async (p?: Person) => (await call(p, "GET", `/api/v1/decorations/${room}`)).json;
|
||||
expect(await view()).toEqual({ instance: room, contents: [g], hash: hash([g]) });
|
||||
expect(await view(kid)).toEqual({ instance: room, contents: [g], hash: hash([g]) });
|
||||
expect(await view(grown)).toEqual({ instance: room, contents: [g, a], hash: hash([g, a]) });
|
||||
|
||||
// A product kept but hidden comes back once it has a usable revision again.
|
||||
await db.update(schema.productRevision).set({ retracted: false }).where(eq(schema.productRevision.productId, "eeeeeeeeee"));
|
||||
expect((await view(kid)).contents).toEqual([g, r]);
|
||||
|
||||
// Changes change the hash.
|
||||
const before = (await view(kid)).hash;
|
||||
const moved = [1, "cccccccccc", "moved"];
|
||||
await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [moved, a, r] });
|
||||
const after = await view(kid);
|
||||
expect(after.contents).toEqual([moved, r]);
|
||||
expect(after.hash).not.toBe(before);
|
||||
|
||||
// Several rooms at once, in the order asked.
|
||||
const many = await call(grown, "GET", `/api/v1/decorations?instances=601-9,${room}`);
|
||||
expect(many.json.rooms).toEqual([
|
||||
{ instance: "601-9", contents: [], hash: hash([]) },
|
||||
{ instance: room, contents: [moved, a, r], hash: hash([moved, a, r]) },
|
||||
]);
|
||||
expect((await call(undefined, "GET", "/api/v1/decorations")).json).toEqual({ rooms: [] });
|
||||
const tooMany = Array.from({ length: 21 }, (_, i) => `601-${i + 1}`).join(",");
|
||||
expect((await call(undefined, "GET", `/api/v1/decorations?instances=${tooMany}`)).status).toBe(400);
|
||||
expect((await call(undefined, "GET", "/api/v1/decorations?instances=601-1,nope")).status).toBe(400);
|
||||
|
||||
// An empty list removes them.
|
||||
const cleared = await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [] });
|
||||
expect(cleared.json).toEqual({ instance: room, contents: [], hash: hash([]) });
|
||||
expect(await db.select().from(schema.roomDecoration).where(eq(schema.roomDecoration.instanceId, room))).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("proxies in the room are told when its decorations change", async () => {
|
||||
const owner = await person("fay", { id: 701 });
|
||||
const guest = await person("finn", { id: 711 });
|
||||
const away = await person("fred", { id: 721 });
|
||||
await product("ffffffffff", owner.id, "general");
|
||||
const room = "701-2";
|
||||
|
||||
const host = await proxy(owner, 701); // the owner's client, in the room
|
||||
const editor = await proxy(owner, 701); // the owner's other session, saving (not in the room)
|
||||
const inRoom = await proxy(guest, 711);
|
||||
const elsewhere = await proxy(away, 721);
|
||||
host.send({ type: "join", chat: "1400000001", room });
|
||||
inRoom.send({ type: "join", chat: "1400000001", room });
|
||||
elsewhere.send({ type: "join", chat: "1400000002", room: "701-3" });
|
||||
elsewhere.send({ type: "join", chat: "1400000003" });
|
||||
await Bun.sleep(50);
|
||||
|
||||
expect((await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [[1, "ffffffffff", ""]] })).status).toBe(200);
|
||||
await waitFor(() => host.decor().length === 1 && inRoom.decor().length === 1);
|
||||
expect(inRoom.decor()[0]).toEqual({ type: "decor", room });
|
||||
await Bun.sleep(50);
|
||||
expect(editor.decor()).toHaveLength(0);
|
||||
expect(elsewhere.decor()).toHaveLength(0);
|
||||
|
||||
// Not after leaving.
|
||||
inRoom.send({ type: "leave", chat: "1400000001" });
|
||||
await Bun.sleep(30);
|
||||
await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [] });
|
||||
await waitFor(() => host.decor().length === 2);
|
||||
await Bun.sleep(50);
|
||||
expect(inRoom.decor()).toHaveLength(1);
|
||||
|
||||
// A refused change tells no one.
|
||||
await call(guest, "PUT", `/api/v1/decorations/${room}`, { contents: [] });
|
||||
await Bun.sleep(50);
|
||||
expect(host.decor()).toHaveLength(2);
|
||||
|
||||
for (const p of [host, editor, inRoom, elsewhere]) p.ws.close();
|
||||
});
|
||||
|
||||
+16
-2
@@ -11,7 +11,7 @@ cookies. T5DE's proxy calls it directly with `Authorization: Bearer <session tok
|
||||
|
||||
Errors are `{"error": "<code>", "message": "<text for people>"}` with a 4xx/5xx status; `message` can be shown as
|
||||
is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_account`, `already_linked`,
|
||||
`code_missing`, `expired`, `link_cap`, `rate_limited`, `imvu_unavailable`.
|
||||
`code_missing`, `expired`, `link_cap`, `rate_limited`, `imvu_unavailable`, `unknown_product`, `hosted_room`.
|
||||
|
||||
## Accounts (Better Auth)
|
||||
|
||||
@@ -30,7 +30,7 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
|
||||
| `POST /api/auth/device/token` | `{grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code, client_id}` → `{access_token}` (a session token, used as a bearer token) or `{error: authorization_pending \| slow_down \| expired_token \| access_denied}` |
|
||||
| `POST /api/v1/device/approve` | (website, signed in) `{code}`: signs the waiting proxy in as this person |
|
||||
| `POST /api/v1/badges` | (bearer or website session) `{imvuIds}` (at most 200 IMVU account IDs) → `{badges}`: those linked to a T5DE account that turned on `showBadge` for them and isn't banned. Nothing else about them. 120 a minute per person. |
|
||||
| `GET /api/v1/realtime` | WebSocket, bearer token: presence, outfits, the room channel and hosted rooms (`room.*`, docs/hosted-rooms.md). Protocol in `apps/api/src/realtime.ts`. |
|
||||
| `GET /api/v1/realtime` | WebSocket, bearer token: presence, outfits, the room channel, decoration notices (`decor`) and hosted rooms (`room.*`, docs/hosted-rooms.md). Protocol in `apps/api/src/realtime.ts`. |
|
||||
| `GET /.well-known/jwks.json` | the Ed25519 keys JWTs are signed with |
|
||||
|
||||
## Website routes (signed in unless noted)
|
||||
@@ -67,6 +67,20 @@ the `GET`s also signed out.
|
||||
A room is `{n, instanceId, chatId, name, description, roomPid, rating, maxUsers, ownerImvuId, ownerName, occupancy,
|
||||
createdAt}`; `instanceId` is `2100000000-<n>`, `chatId` 2100000000 + n, `occupancy` the people in it now.
|
||||
|
||||
## Decorations
|
||||
|
||||
T5 products placed in ordinary IMVU rooms (docs/products.md, "Decorations"). `instance` is IMVU's room instance,
|
||||
`<owner's customer ID>-<n>` (`^[1-9][0-9]{0,11}-[1-9][0-9]{0,9}$`); hosted rooms' (`2100000000-<n>`) are refused
|
||||
(400 `hosted_room`: they have their own scene). A room's decorations are `{instance, contents, hash}`: `contents`
|
||||
is a list of `[furniture instance ID (integer), hub product ID, placement (up to 200 characters)]`, `hash` the
|
||||
sha256 (hex) of `contents` as JSON.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/decorations/:instance` | (anyone, also signed out) the room's decorations as the viewer sees them: only products with a revision that isn't retracted, adult ones only for people with an adult account. A room without any: `contents: []`. |
|
||||
| `GET /api/v1/decorations?instances=a,b` | (anyone) `{rooms: [{instance, contents, hash}]}`, up to 20 rooms, in the order asked |
|
||||
| `PUT /api/v1/decorations/:instance` | (bearer or website session) `{contents}` → the room's decorations as the person sees them. Only if one of the person's linked IMVU accounts is the room's owner (else 403 `forbidden`). Up to 300 items, each furniture ID once; every product must be registered (400 `unknown_product`). An empty list removes them. Everyone with a chat in the room gets `{"type": "decor", "room": instance}` on the realtime connection. 60 a minute. |
|
||||
|
||||
## Products, catalogs and libraries
|
||||
|
||||
The routes and tokens are in docs/products.md. Products are `{id, name, description, rating, latest, creator,
|
||||
|
||||
+2
-2
@@ -287,8 +287,8 @@ is per person, shared across their accounts.
|
||||
|
||||
## Rooms (**Decided**: both)
|
||||
|
||||
- **Decorations over IMVU rooms**: community furniture placed in an IMVU room, seen only by T5DE users. Placements
|
||||
travel on the room channel like outfits.
|
||||
- **Decorations over IMVU rooms**: community furniture placed in an IMVU room, seen only by T5DE users. The hub keeps
|
||||
them per room; proxies strip them from what IMVU gets and add them back on load (products.md, "Decorations").
|
||||
- **T5DE-hosted rooms**: the room lives on the hub: scene, furniture placement, owner, moderators, permissions,
|
||||
bans, chat, seats, presence. The proxy routes that room's IMQ traffic to the hub, which speaks the subset of
|
||||
IMQ the client needs (subscribe, state changes, mounts, messages) and the room-joining XML-RPC calls, so the
|
||||
|
||||
@@ -99,3 +99,20 @@ every 10 minutes, 20 at a time.
|
||||
From there it's served like any pack.
|
||||
- Products others wear in a room that it doesn't have are fetched the same way, then given to the client.
|
||||
- Library products appear in the client's inventory (see the proxy's README).
|
||||
|
||||
## Decorations
|
||||
|
||||
T5 furniture and pose products placed in an ordinary IMVU room (design.md, "Decorations over IMVU rooms"). IMVU
|
||||
keeps the room without them; the hub keeps them (`room_decoration`, routes in api.md), and only T5DE users see them.
|
||||
|
||||
- **Saving**: when the room's owner saves the room (`test.setRoomState`) or the client broadcasts a furniture change
|
||||
in it, the proxy takes the hub products out before it goes to IMVU, and `PUT /api/v1/decorations/<instance>` the
|
||||
room's full list of them (`[furniture instance ID, hub product ID, placement]`).
|
||||
- **Loading**: when the client loads an IMVU room (`test.getRoomState`), the proxy `GET`s its decorations and adds
|
||||
them to the room's contents (as local IDs, fetching the products as for outfits); the `hash` tells it whether
|
||||
what it has is current. In the room, an incoming furniture broadcast from IMVU gets the decorations added back
|
||||
the same way.
|
||||
- **Changes**: after a `PUT`, every proxy with a chat in that room gets `{"type": "decor", "room": instance}` on the
|
||||
realtime connection and `GET`s again.
|
||||
- Viewers only get products they may see: registered, with a revision that isn't retracted, adult ones only for
|
||||
adult accounts. Furniture instance IDs are the proxy's to choose; they must not clash with IMVU's in that room.
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE "room_decoration" (
|
||||
"instance_id" text PRIMARY KEY NOT NULL,
|
||||
"owner_imvu_id" bigint NOT NULL,
|
||||
"contents" jsonb NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "room_decoration_owner" ON "room_decoration" USING btree ("owner_imvu_id");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -43,6 +43,13 @@
|
||||
"when": 1793355138984,
|
||||
"tag": "0005_hosted_rooms",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1793357692630,
|
||||
"tag": "0006_decorations",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export { schema };
|
||||
export type { FileEntry, ParentInfo, SceneItem } from "./schema";
|
||||
export type { DecorationItem, FileEntry, ParentInfo, SceneItem } from "./schema";
|
||||
export * from "drizzle-orm";
|
||||
|
||||
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
|
||||
|
||||
@@ -361,3 +361,22 @@ export const hostedRoom = pgTable(
|
||||
},
|
||||
(t) => [index("hosted_room_owner").on(t.ownerId), index("hosted_room_created").on(t.createdAt)],
|
||||
);
|
||||
|
||||
// ---- Decorations over IMVU rooms (docs/products.md, "Decorations") ----
|
||||
|
||||
/** A decoration: [furniture instance ID, hub product ID, placement], as the client's room_contents. */
|
||||
export type DecorationItem = [number, string, string];
|
||||
|
||||
// T5 products placed in an IMVU room by its owner. IMVU keeps the room without them (T5DE's proxy strips them from
|
||||
// what the client saves); the proxies of T5DE users in the room add them back. Hosted rooms keep theirs in
|
||||
// hosted_room.scene instead.
|
||||
export const roomDecoration = pgTable(
|
||||
"room_decoration",
|
||||
{
|
||||
instanceId: text("instance_id").primaryKey(), // IMVU's room instance, "<owner>-<n>"
|
||||
ownerImvuId: bigint("owner_imvu_id", { mode: "number" }).notNull(), // the number before the dash
|
||||
contents: jsonb("contents").$type<DecorationItem[]>().notNull(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index("room_decoration_owner").on(t.ownerImvuId)],
|
||||
);
|
||||
Reference in new issue
Block a user