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:
1 parent
0c67232425
commit
2a1fc94df6
18 files changed
+3138
-52
No files matched your search
+125
-1
@@ -11,6 +11,7 @@ import { Realtime } from "./realtime";
|
|||||||
import { isAdultRoom, Rooms } from "./rooms";
|
import { isAdultRoom, Rooms } from "./rooms";
|
||||||
import { Signer } from "./keys";
|
import { Signer } from "./keys";
|
||||||
import { ProductError, Products } from "./products";
|
import { ProductError, Products } from "./products";
|
||||||
|
import { chatId, HostedError, HostedRooms, instanceId, maxPlacement, maxSceneItems } from "./hosted";
|
||||||
|
|
||||||
const { user, session, imvuLink, linkChallenge, audit } = schema;
|
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 signer = new Signer(db, apiURL ?? "http://localhost:4101");
|
||||||
const products = new Products(db, signer, imvu);
|
const products = new Products(db, signer, imvu);
|
||||||
const rooms = roomsService ?? new Rooms(db, imvu);
|
const rooms = roomsService ?? new Rooms(db, imvu);
|
||||||
|
const hosted = new HostedRooms(db);
|
||||||
const realtime =
|
const realtime =
|
||||||
rt ??
|
rt ??
|
||||||
new Realtime({
|
new Realtime({
|
||||||
@@ -89,6 +91,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
|||||||
room: (instanceId) => {
|
room: (instanceId) => {
|
||||||
rooms.seen(instanceId).catch((e) => console.error("rooms:", e));
|
rooms.seen(instanceId).catch((e) => console.error("rooms:", e));
|
||||||
},
|
},
|
||||||
|
hostedRoom: (n) => hosted.info(n),
|
||||||
});
|
});
|
||||||
const limiter = new Limiter();
|
const limiter = new Limiter();
|
||||||
|
|
||||||
@@ -210,6 +213,41 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
|||||||
createdAt: c.createdAt.toISOString(),
|
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 productParam = t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }) });
|
||||||
|
|
||||||
const ownLink = async (p: Person, imvuId: number) => {
|
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 };
|
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.
|
// Catalogs: registering one, and the directory.
|
||||||
.post(
|
.post(
|
||||||
"/catalogs",
|
"/catalogs",
|
||||||
@@ -1015,7 +1139,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
|||||||
.get("/health", () => ({ ok: true }))
|
.get("/health", () => ({ ok: true }))
|
||||||
.use(api);
|
.use(api);
|
||||||
|
|
||||||
return { app, realtime, age, rooms, products, signer };
|
return { app, realtime, age, rooms, products, signer, hosted };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type App = ReturnType<typeof createApp>["app"];
|
export type App = ReturnType<typeof createApp>["app"];
|
||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
// The live connection with signed-in proxies: which IMVU account each client is using (presence), the community
|
// 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
|
// products it wears, the IMVU chats it's in (the room channel: T5DE users in the same chat see each other's
|
||||||
// community products).
|
// 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
|
// Protocol (JSON text messages over the WebSocket at /api/v1/realtime, authorized with the proxy's session token as
|
||||||
// a bearer token):
|
// 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
|
// 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.
|
// products only go to clients whose IMVU account counts as adult.
|
||||||
// hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."}
|
// 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 Item = { id: string; name: string; adult: boolean };
|
||||||
|
|
||||||
export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[]; rooms: string[] };
|
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).
|
// 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.
|
// 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 = {
|
type Session = {
|
||||||
userId: string;
|
userId: string;
|
||||||
imvuId: number;
|
imvuId: number;
|
||||||
@@ -29,10 +56,17 @@ type Session = {
|
|||||||
outfit: Item[];
|
outfit: Item[];
|
||||||
chats: Map<string, string>;
|
chats: Map<string, string>;
|
||||||
lastSent: Map<string, string>;
|
lastSent: Map<string, string>;
|
||||||
|
hosted: Set<number>;
|
||||||
|
tokens: number;
|
||||||
|
refilled: number;
|
||||||
send: (message: object) => void;
|
send: (message: object) => void;
|
||||||
close: () => 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 = {
|
export type RealtimeDeps = {
|
||||||
/** The person an IMVU account is linked to. */
|
/** The person an IMVU account is linked to. */
|
||||||
owner: (imvuId: number) => Promise<string | undefined>;
|
owner: (imvuId: number) => Promise<string | undefined>;
|
||||||
@@ -40,20 +74,37 @@ export type RealtimeDeps = {
|
|||||||
adult?: (userId: string, imvuId: number) => Promise<boolean>;
|
adult?: (userId: string, imvuId: number) => Promise<boolean>;
|
||||||
/** A client entered an IMVU room (to learn about rooms for the rooms list). */
|
/** A client entered an IMVU room (to learn about rooms for the rooms list). */
|
||||||
room?: (instanceId: string) => void;
|
room?: (instanceId: string) => void;
|
||||||
|
/** A hosted room (undefined: there's none by that number). */
|
||||||
|
hostedRoom?: (n: number) => Promise<HostedRoomInfo | undefined>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const maxChats = 16;
|
const maxChats = 16;
|
||||||
const chatPattern = /^[0-9]{1,20}$/;
|
const chatPattern = /^[0-9]{1,20}$/;
|
||||||
const roomPattern = /^[0-9]{1,12}-[0-9]{1,12}$/;
|
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 {
|
export class Realtime {
|
||||||
private sessions = new Map<string, Session>();
|
private sessions = new Map<string, Session>();
|
||||||
|
private hosted = new Map<number, Hosted>();
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
private readonly deps: Required<RealtimeDeps>;
|
private readonly deps: Required<RealtimeDeps>;
|
||||||
|
|
||||||
constructor(deps: RealtimeDeps | RealtimeDeps["owner"]) {
|
constructor(deps: RealtimeDeps | RealtimeDeps["owner"]) {
|
||||||
const d = typeof deps === "function" ? { owner: deps } : deps;
|
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. */
|
/** Calls fn on every change of presence, outfits or rooms; returns a function that stops it. */
|
||||||
@@ -94,6 +145,9 @@ export class Realtime {
|
|||||||
outfit: [],
|
outfit: [],
|
||||||
chats: new Map(),
|
chats: new Map(),
|
||||||
lastSent: new Map(),
|
lastSent: new Map(),
|
||||||
|
hosted: new Set(),
|
||||||
|
tokens: sendBurst,
|
||||||
|
refilled: Date.now(),
|
||||||
send,
|
send,
|
||||||
close,
|
close,
|
||||||
});
|
});
|
||||||
@@ -101,6 +155,7 @@ export class Realtime {
|
|||||||
|
|
||||||
close(id: string) {
|
close(id: string) {
|
||||||
const s = this.sessions.get(id);
|
const s = this.sessions.get(id);
|
||||||
|
if (s) this.leaveAllHosted(id, s, false);
|
||||||
this.sessions.delete(id);
|
this.sessions.delete(id);
|
||||||
if (s && (s.imvuId || s.chats.size)) this.changed(s.chats.keys());
|
if (s && (s.imvuId || s.chats.size)) this.changed(s.chats.keys());
|
||||||
}
|
}
|
||||||
@@ -125,8 +180,9 @@ export class Realtime {
|
|||||||
unlinked(userId: string, imvuId: number) {
|
unlinked(userId: string, imvuId: number) {
|
||||||
const chats: string[] = [];
|
const chats: string[] = [];
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const s of this.sessions.values()) {
|
for (const [id, s] of this.sessions) {
|
||||||
if (s.userId === userId && s.imvuId === imvuId) {
|
if (s.userId === userId && s.imvuId === imvuId) {
|
||||||
|
this.leaveAllHosted(id, s, true);
|
||||||
s.imvuId = 0;
|
s.imvuId = 0;
|
||||||
s.adult = false;
|
s.adult = false;
|
||||||
s.outfit = [];
|
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. */
|
/** Handles a message from a proxy; returns the reply to send, if any. */
|
||||||
async message(id: string, raw: unknown): Promise<object | undefined> {
|
async message(id: string, raw: unknown): Promise<object | undefined> {
|
||||||
const s = this.sessions.get(id);
|
const s = this.sessions.get(id);
|
||||||
@@ -181,12 +346,17 @@ export class Realtime {
|
|||||||
items?: unknown;
|
items?: unknown;
|
||||||
chat?: unknown;
|
chat?: unknown;
|
||||||
room?: unknown;
|
room?: unknown;
|
||||||
|
seat?: unknown;
|
||||||
|
mount?: unknown;
|
||||||
|
message?: unknown;
|
||||||
};
|
};
|
||||||
if (!m || typeof m !== "object") return { type: "error", message: "not a message" };
|
if (!m || typeof m !== "object") return { type: "error", message: "not a message" };
|
||||||
if (m.type === "session") {
|
if (m.type === "session") {
|
||||||
const imvuId = Number(m.imvu_id);
|
const imvuId = Number(m.imvu_id);
|
||||||
s.requested = Number.isSafeInteger(imvuId) && imvuId > 0 ? imvuId : 0;
|
s.requested = Number.isSafeInteger(imvuId) && imvuId > 0 ? imvuId : 0;
|
||||||
const owner = s.requested ? await this.deps.owner(imvuId) : undefined;
|
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) {
|
if (owner !== s.userId) {
|
||||||
// Not this person's account: no presence, no outfit for it.
|
// Not this person's account: no presence, no outfit for it.
|
||||||
s.imvuId = 0;
|
s.imvuId = 0;
|
||||||
@@ -231,6 +401,22 @@ export class Realtime {
|
|||||||
this.changed([chat]);
|
this.changed([chat]);
|
||||||
return;
|
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" };
|
return { type: "error", message: "unknown message type" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||||
|
import { PageHeader } from "@/components/status";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { FieldGroup } from "@/components/ui/field";
|
||||||
|
import { api, getMe, unwrap } from "@/lib/api";
|
||||||
|
import { closeRoom, saveRoom } from "../../actions";
|
||||||
|
import { RoomFields } from "../../room-fields";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Edit room" };
|
||||||
|
|
||||||
|
export default async function EditRoom({ params }: { params: Promise<{ n: string }> }) {
|
||||||
|
const { n } = await params;
|
||||||
|
if (!/^[0-9]{1,9}$/.test(n)) notFound();
|
||||||
|
const me = await getMe();
|
||||||
|
if (!me) redirect(`/signin?next=/rooms/${n}/edit`);
|
||||||
|
const [{ rooms }, { links }] = await Promise.all([
|
||||||
|
api["hosted-rooms"].get({ query: { mine: "1" } }).then(unwrap),
|
||||||
|
api.account.get().then(unwrap),
|
||||||
|
]);
|
||||||
|
const room = rooms.find((r) => String(r.n) === n);
|
||||||
|
if (!room) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader title={`Edit ${room.name}`}>
|
||||||
|
<Link href="/rooms" className="underline-offset-4 hover:underline">
|
||||||
|
Back to rooms
|
||||||
|
</Link>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>
|
||||||
|
Owned by {room.ownerName} · {room.occupancy} here now
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ActionForm action={saveRoom} fields={{ n: room.n }}>
|
||||||
|
<FieldGroup>
|
||||||
|
<RoomFields room={room} canAdult={links.some((l) => l.adult)} />
|
||||||
|
<div>
|
||||||
|
<SubmitButton pendingLabel="Saving…">Save</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
</ActionForm>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Close room</CardTitle>
|
||||||
|
<CardDescription>Everyone in it is sent out. It can't be opened again.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ActionForm action={closeRoom} fields={{ n: room.n }}>
|
||||||
|
<SubmitButton variant="destructive" pendingLabel="Closing…" confirm={`Close ${room.name}?`}>
|
||||||
|
Close room
|
||||||
|
</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { api, unwrap } from "@/lib/api";
|
||||||
|
import { attempt, field, type ActionState } from "@/lib/actions";
|
||||||
|
|
||||||
|
const rating = (form: FormData) => (field(form, "rating") === "adult" ? ("adult" as const) : ("general" as const));
|
||||||
|
|
||||||
|
/** Makes a hosted room, then goes back to the list. */
|
||||||
|
export async function createRoom(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
const state = await attempt(async () => {
|
||||||
|
unwrap(
|
||||||
|
await api["hosted-rooms"].post({
|
||||||
|
name: field(form, "name"),
|
||||||
|
description: field(form, "description"),
|
||||||
|
roomPid: Number(field(form, "roomPid")),
|
||||||
|
rating: rating(form),
|
||||||
|
maxUsers: Number(field(form, "maxUsers")),
|
||||||
|
imvuId: Number(field(form, "imvuId")),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
revalidatePath("/rooms");
|
||||||
|
});
|
||||||
|
if (state?.error) return state;
|
||||||
|
redirect("/rooms");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The owner's changes to a room. */
|
||||||
|
export async function saveRoom(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
const n = field(form, "n");
|
||||||
|
const state = await attempt(async () => {
|
||||||
|
unwrap(
|
||||||
|
await api["hosted-rooms"]({ n }).patch({
|
||||||
|
name: field(form, "name"),
|
||||||
|
description: field(form, "description"),
|
||||||
|
roomPid: Number(field(form, "roomPid")),
|
||||||
|
rating: rating(form),
|
||||||
|
maxUsers: Number(field(form, "maxUsers")),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
revalidatePath("/rooms");
|
||||||
|
});
|
||||||
|
if (state?.error) return state;
|
||||||
|
redirect("/rooms");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes a room: everyone in it is sent out. */
|
||||||
|
export async function closeRoom(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
const n = field(form, "n");
|
||||||
|
const state = await attempt(async () => {
|
||||||
|
unwrap(await api["hosted-rooms"]({ n }).delete());
|
||||||
|
revalidatePath("/rooms");
|
||||||
|
});
|
||||||
|
if (state?.error) return state;
|
||||||
|
redirect("/rooms");
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||||
|
import { PageHeader } from "@/components/status";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||||
|
import { api, getMe, unwrap } from "@/lib/api";
|
||||||
|
import { createRoom } from "../actions";
|
||||||
|
import { RoomFields } from "../room-fields";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "New room" };
|
||||||
|
|
||||||
|
export default async function NewRoom() {
|
||||||
|
const me = await getMe();
|
||||||
|
if (!me) redirect("/signin?next=/rooms/new");
|
||||||
|
const { links } = await api.account.get().then(unwrap);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader title="New room">
|
||||||
|
<Link href="/rooms" className="underline-offset-4 hover:underline">
|
||||||
|
Back to rooms
|
||||||
|
</Link>
|
||||||
|
</PageHeader>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
{links.length === 0 ? (
|
||||||
|
<p className="py-2 text-sm text-muted-foreground">
|
||||||
|
<Link href="/account" className="text-foreground underline underline-offset-4">
|
||||||
|
Link an IMVU account
|
||||||
|
</Link>{" "}
|
||||||
|
first: it's shown as the room's owner.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ActionForm action={createRoom}>
|
||||||
|
<FieldGroup>
|
||||||
|
<RoomFields canAdult={links.some((l) => l.adult)} />
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="imvuId">Owner</FieldLabel>
|
||||||
|
<NativeSelect id="imvuId" name="imvuId" defaultValue={String(links[0]!.imvuId)} className="w-64">
|
||||||
|
{links.map((l) => (
|
||||||
|
<NativeSelectOption key={l.imvuId} value={String(l.imvuId)}>
|
||||||
|
{l.username}
|
||||||
|
</NativeSelectOption>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
<FieldDescription>The IMVU account shown as owner. It can decorate the room.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
<div>
|
||||||
|
<SubmitButton pendingLabel="Making…">Make room</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
</ActionForm>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+110
-44
@@ -1,55 +1,121 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
import { PageHeader, Tag } from "@/components/status";
|
import { PageHeader, Tag } from "@/components/status";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { api, unwrap } from "@/lib/api";
|
import { api, getMe, unwrap, type HostedRoom } from "@/lib/api";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Rooms" };
|
export const metadata: Metadata = { title: "Rooms" };
|
||||||
|
|
||||||
export default async function Rooms() {
|
export default async function Rooms() {
|
||||||
const { rooms } = await api.rooms.get().then(unwrap);
|
const me = await getMe();
|
||||||
|
const [{ rooms }, { rooms: hosted }, mine] = await Promise.all([
|
||||||
|
api.rooms.get().then(unwrap),
|
||||||
|
api["hosted-rooms"].get({ query: {} }).then(unwrap),
|
||||||
|
me ? api["hosted-rooms"].get({ query: { mine: "1" } }).then(unwrap) : { rooms: [] as HostedRoom[] },
|
||||||
|
]);
|
||||||
|
const own = new Set(mine.rooms.map((r) => r.n));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-10">
|
||||||
<PageHeader title="Rooms">Public IMVU rooms of T5DE users.</PageHeader>
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
{rooms.length === 0 ? (
|
<PageHeader title="Rooms">Rooms on T5DE, and public IMVU rooms of T5DE users.</PageHeader>
|
||||||
<Card>
|
{me && (
|
||||||
<CardContent>
|
<Link href="/rooms/new" className={buttonVariants({ className: "px-4" })}>
|
||||||
<p className="py-6 text-sm text-muted-foreground">No rooms listed yet.</p>
|
New room
|
||||||
</CardContent>
|
</Link>
|
||||||
</Card>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<ul className="grid gap-4 sm:grid-cols-2">
|
|
||||||
{rooms.map((r) => (
|
<section className="space-y-4">
|
||||||
<li key={r.instanceId}>
|
<h2 className="text-lg font-semibold tracking-tight">On T5DE</h2>
|
||||||
<Card className="h-full overflow-hidden py-0">
|
{hosted.length === 0 ? (
|
||||||
{r.imageURL && (
|
<Card>
|
||||||
// IMVU's own image of the room, from IMVU.
|
<CardContent>
|
||||||
<img src={r.imageURL} alt="" className="aspect-[332/281] w-full bg-muted object-cover" loading="lazy" />
|
<p className="py-6 text-sm text-muted-foreground">No rooms yet.</p>
|
||||||
)}
|
</CardContent>
|
||||||
<CardContent className="space-y-3 pb-6">
|
</Card>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
) : (
|
||||||
<a href={r.url} className="font-medium underline-offset-4 hover:underline" rel="noreferrer">
|
<ul className="grid gap-4 sm:grid-cols-2">
|
||||||
{r.name}
|
{hosted.map((r) => (
|
||||||
</a>
|
<li key={r.n}>
|
||||||
{r.adult && <Tag tone="adult">AP</Tag>}
|
<HostedCard room={r} mine={own.has(r.n)} />
|
||||||
{r.t5de.length > 0 && (
|
</li>
|
||||||
<Tag tone="online" dot>
|
))}
|
||||||
{r.t5de.length} on T5DE
|
</ul>
|
||||||
</Tag>
|
)}
|
||||||
)}
|
</section>
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<section className="space-y-4">
|
||||||
{r.owner.username} · {r.occupancy}/{r.capacity}
|
<h2 className="text-lg font-semibold tracking-tight">On IMVU</h2>
|
||||||
</p>
|
{rooms.length === 0 ? (
|
||||||
{r.description && <p className="line-clamp-3 text-sm whitespace-pre-line">{r.description}</p>}
|
<Card>
|
||||||
{r.t5de.length > 0 && (
|
<CardContent>
|
||||||
<p className="text-xs text-muted-foreground">Here now: {r.t5de.map((u) => u.username).join(", ")}</p>
|
<p className="py-6 text-sm text-muted-foreground">No rooms listed yet.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{rooms.map((r) => (
|
||||||
|
<li key={r.instanceId}>
|
||||||
|
<Card className="h-full overflow-hidden py-0">
|
||||||
|
{r.imageURL && (
|
||||||
|
// IMVU's own image of the room, from IMVU.
|
||||||
|
<img src={r.imageURL} alt="" className="aspect-[332/281] w-full bg-muted object-cover" loading="lazy" />
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
<CardContent className="space-y-3 pb-6">
|
||||||
</Card>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
</li>
|
<a href={r.url} className="font-medium underline-offset-4 hover:underline" rel="noreferrer">
|
||||||
))}
|
{r.name}
|
||||||
</ul>
|
</a>
|
||||||
)}
|
{r.adult && <Tag tone="adult">AP</Tag>}
|
||||||
</>
|
{r.t5de.length > 0 && (
|
||||||
|
<Tag tone="online" dot>
|
||||||
|
{r.t5de.length} on T5DE
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{r.owner.username} · {r.occupancy}/{r.capacity}
|
||||||
|
</p>
|
||||||
|
{r.description && <p className="line-clamp-3 text-sm whitespace-pre-line">{r.description}</p>}
|
||||||
|
{r.t5de.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">Here now: {r.t5de.map((u) => u.username).join(", ")}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostedCard({ room: r, mine }: { room: HostedRoom; mine: boolean }) {
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{r.name}</span>
|
||||||
|
{r.rating === "adult" && <Tag tone="adult">Adult</Tag>}
|
||||||
|
{r.occupancy > 0 && (
|
||||||
|
<Tag tone="online" dot>
|
||||||
|
{r.occupancy} here
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{mine && (
|
||||||
|
<Link href={`/rooms/${r.n}/edit`} className={buttonVariants({ variant: "outline", size: "sm", className: "ml-auto" })}>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{r.ownerName} · {r.occupancy}/{r.maxUsers}
|
||||||
|
</p>
|
||||||
|
{r.description && <p className="line-clamp-3 text-sm whitespace-pre-line">{r.description}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// The fields of a hosted room, for making one and for editing it.
|
||||||
|
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import type { HostedRoom } from "@/lib/api";
|
||||||
|
|
||||||
|
export function RoomFields({ room, canAdult }: { room?: HostedRoom; canAdult: boolean }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="name">Name</FieldLabel>
|
||||||
|
<Input id="name" name="name" defaultValue={room?.name} required maxLength={64} className="max-w-md" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="description">Description</FieldLabel>
|
||||||
|
<Textarea id="description" name="description" defaultValue={room?.description} maxLength={512} rows={3} />
|
||||||
|
</Field>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="roomPid">Room shell</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="roomPid"
|
||||||
|
name="roomPid"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1}
|
||||||
|
required
|
||||||
|
defaultValue={room?.roomPid}
|
||||||
|
placeholder="Product ID"
|
||||||
|
/>
|
||||||
|
<FieldDescription>An IMVU room product's ID, from its shop page.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="rating">Rating</FieldLabel>
|
||||||
|
<NativeSelect id="rating" name="rating" defaultValue={room?.rating ?? "general"} className="w-full">
|
||||||
|
<NativeSelectOption value="general">General</NativeSelectOption>
|
||||||
|
<NativeSelectOption value="adult" disabled={!canAdult && room?.rating !== "adult"}>
|
||||||
|
Adult
|
||||||
|
</NativeSelectOption>
|
||||||
|
</NativeSelect>
|
||||||
|
{!canAdult && <FieldDescription>Adult needs an age-verified account.</FieldDescription>}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="maxUsers">Max people</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="maxUsers"
|
||||||
|
name="maxUsers"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={2}
|
||||||
|
max={30}
|
||||||
|
required
|
||||||
|
defaultValue={room?.maxUsers ?? 10}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ export type ProductPage = Data<ReturnType<V1["products"]>["get"]>;
|
|||||||
export type Revision = ProductPage["revisions"][number];
|
export type Revision = ProductPage["revisions"][number];
|
||||||
export type Catalog = Data<V1["catalogs"]["get"]>["catalogs"][number];
|
export type Catalog = Data<V1["catalogs"]["get"]>["catalogs"][number];
|
||||||
export type LibraryEntry = Data<V1["library"]["get"]>["library"][number];
|
export type LibraryEntry = Data<V1["library"]["get"]>["library"][number];
|
||||||
|
export type HostedRoom = Data<V1["hosted-rooms"]["get"]>["rooms"][number];
|
||||||
|
|
||||||
/** An error the API answered: message is written to be shown as is. */
|
/** An error the API answered: message is written to be shown as is. */
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
|
|||||||
+18
-1
@@ -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/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/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. |
|
| `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, and the room channel. Protocol in `apps/api/src/realtime.ts`. |
|
| `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 /.well-known/jwks.json` | the Ed25519 keys JWTs are signed with |
|
| `GET /.well-known/jwks.json` | the Ed25519 keys JWTs are signed with |
|
||||||
|
|
||||||
## Website routes (signed in unless noted)
|
## Website routes (signed in unless noted)
|
||||||
@@ -50,6 +50,23 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
|
|||||||
| `DELETE /api/v1/account/links/:imvu` | unlink |
|
| `DELETE /api/v1/account/links/:imvu` | unlink |
|
||||||
| `POST /api/v1/account/signout-computers` | signs T5DE out on all their computers |
|
| `POST /api/v1/account/signout-computers` | signs T5DE out on all their computers |
|
||||||
|
|
||||||
|
## Hosted rooms
|
||||||
|
|
||||||
|
Rooms the hub hosts (docs/hosted-rooms.md, which has the details and the realtime part). Bearer or website session;
|
||||||
|
the `GET`s also signed out.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| `POST /api/v1/hosted-rooms` | `{name, description?, roomPid, rating, maxUsers?, imvuId}` → 201 `{room}`. `imvuId`: one of the person's linked accounts, shown as owner. Name 1-64 characters, description up to 512 (plain text), `maxUsers` 2-30 (default 10), `rating` `general` or `adult` (adult people only). Up to 10 open rooms a person (`room_cap`), 20 new ones an hour. |
|
||||||
|
| `GET /api/v1/hosted-rooms` | `{rooms}`: open rooms, newest first (at most 200); adult ones for adults only. `?mine=1`: the person's own. |
|
||||||
|
| `GET /api/v1/hosted-rooms/:n` | `{room, scene: {contents, hash}}`; 404, or 410 `closed` |
|
||||||
|
| `PATCH /api/v1/hosted-rooms/:n` | (owner) `{name?, description?, rating?, maxUsers?, roomPid?}` → `{room}`. Made adult: non-adults in it are sent out. |
|
||||||
|
| `PUT /api/v1/hosted-rooms/:n/scene` | (owner) `{contents, roomPid?}` → `{room, scene}`. `contents`: up to 500 `[furniture ID, product ID (integer or hub ID), placement (up to 200 characters)]`. 60 a minute. |
|
||||||
|
| `DELETE /api/v1/hosted-rooms/:n` | (owner or moderator) closes it for good; everyone in it gets `room.closed` |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Products, catalogs and libraries
|
## Products, catalogs and libraries
|
||||||
|
|
||||||
The routes and tokens are in docs/products.md. Products are `{id, name, description, rating, latest, creator,
|
The routes and tokens are in docs/products.md. Products are `{id, name, description, rating, latest, creator,
|
||||||
|
|||||||
+3
-1
@@ -411,7 +411,9 @@ The proxy streams and adds no waiting.
|
|||||||
- Phase 1 (local packs) done: t5de-api, t5de-proxy `internal/products`.
|
- Phase 1 (local packs) done: t5de-api, t5de-proxy `internal/products`.
|
||||||
- Phase 2 (hub identity) under way: accounts, tagline linking, age (vendor-neutral, IMVU's re-checked daily), link
|
- Phase 2 (hub identity) under way: accounts, tagline linking, age (vendor-neutral, IMVU's re-checked daily), link
|
||||||
caps, device sign-in, presence and badge, console, the room channel and the rooms list. Not yet: a sign-in button
|
caps, device sign-in, presence and badge, console, the room channel and the rooms list. Not yet: a sign-in button
|
||||||
in the client (the proxy's `signin` command for now), deployment, hosted rooms. The room channel is tested with
|
in the client (the proxy's `signin` command for now), deployment.
|
||||||
|
- Hosted rooms (phase 5, docs/hosted-rooms.md): the hub's side is done (rooms made on the website, REST, the
|
||||||
|
realtime `room.*` protocol, tested with fake proxies); the proxy's side is under way. The room channel is tested with
|
||||||
fake proxies and one live client; two live clients need a second throwaway account.
|
fake proxies and one live client; two live clients need a second throwaway account.
|
||||||
- **Stack changed** (2026-10-29, the user's choice): the hub is a Bun monorepo: Elysia API, Better Auth, Drizzle on
|
- **Stack changed** (2026-10-29, the user's choice): the hub is a Bun monorepo: Elysia API, Better Auth, Drizzle on
|
||||||
Postgres, Next.js website with Tailwind and shadcn/ui (README). It replaces the first version, in Go. Where this
|
Postgres, Next.js website with Tailwind and shadcn/ui (README). It replaces the first version, in Go. Where this
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# T5DE-hosted rooms (phase 1)
|
||||||
|
|
||||||
|
A hosted room lives on the hub (design.md, Rooms): T5DE users find it in the client's room list, join it, sit and
|
||||||
|
chat, and its owner decorates it. The client treats it as an ordinary IMVU room: T5DE's proxy answers the client's
|
||||||
|
room calls and runs the room's IMQ queue against the hub (imq-rooms.md has what the client expects). IMVU never
|
||||||
|
hears of hosted rooms.
|
||||||
|
|
||||||
|
Phase 1: rooms created on the website; joining, seats, chat (`messages` and `control` mounts), the scene (the
|
||||||
|
owner's furniture), occupancy in the room list. Later: moderators, bans and boots, invitations, private rooms.
|
||||||
|
|
||||||
|
## IDs
|
||||||
|
|
||||||
|
IDs IMVU won't use, so the proxy can tell hosted rooms apart:
|
||||||
|
|
||||||
|
- A room has a number `n` (1, 2, ...), its **instance ID** is `2100000000-<n>` and its **chat ID** is
|
||||||
|
`2100000000 + n`. (IMVU's chat IDs were about 1,390,000,000 in 2026-10, and XML-RPC integers end at
|
||||||
|
2,147,483,647; IMVU customer IDs, the first part of its instance IDs, are far below 2,100,000,000.)
|
||||||
|
- The room shell is an IMVU product (`roomPid`), chosen when the room is made (any room product; the client loads
|
||||||
|
it like any room).
|
||||||
|
|
||||||
|
## REST (bearer or cookie session)
|
||||||
|
|
||||||
|
| Route | |
|
||||||
|
| --- | --- |
|
||||||
|
| `POST /api/v1/hosted-rooms` | `{name, description, roomPid, rating, maxUsers, imvuId}`: make a room. `imvuId` is one of the person's linked IMVU accounts, shown as the owner. `rating` `general` or `adult` (adult needs an adult person). `maxUsers` 2-30, default 10. Name 1-64 characters, description up to 512. A person may own up to 10 open rooms. |
|
||||||
|
| `GET /api/v1/hosted-rooms` | (also signed out) `{rooms: [room]}`, open rooms, newest first; adult rooms only for adults. `?mine=1`: the person's own. |
|
||||||
|
| `GET /api/v1/hosted-rooms/:n` | (also signed out) `{room, scene: {contents, hash}}`; 404 `not_found` (or adult, for others), 410 `closed`. |
|
||||||
|
| `PATCH /api/v1/hosted-rooms/:n` | owner: `{name?, description?, rating?, maxUsers?, roomPid?}`. |
|
||||||
|
| `PUT /api/v1/hosted-rooms/:n/scene` | owner: `{contents, roomPid?}`, from the client's `test.setRoomState`. |
|
||||||
|
| `DELETE /api/v1/hosted-rooms/:n` | owner (or moderator console): closes it; everyone in it is told (`room.closed`). |
|
||||||
|
|
||||||
|
A room: `{n, instanceId, chatId, name, description, roomPid, rating, maxUsers, ownerImvuId, ownerName, occupancy,
|
||||||
|
createdAt}` (`ownerName`: the owner's IMVU name as linked; `occupancy`: people in it now). A room whose owner account
|
||||||
|
is no longer linked to its owner (unlinked, or moved to another person), or whose owner is banned, is hidden (not
|
||||||
|
listed, `not_found`) until it's linked again. Closed rooms stay closed; their numbers aren't reused.
|
||||||
|
|
||||||
|
The scene's `contents` is the client's `room_contents`: a list of `[furniture instance ID (integer), product ID
|
||||||
|
(integer or a hub product ID string), placement (string)]`, up to 500 items, placement up to 200 characters. `hash`
|
||||||
|
is a hash of the stored scene (the client caches scenes by it): sha256, hex, of `[roomPid, contents]` as JSON.
|
||||||
|
|
||||||
|
## Realtime (the proxy's WebSocket)
|
||||||
|
|
||||||
|
From the proxy (the session's IMVU account, sent with `session`, must be linked to the person):
|
||||||
|
|
||||||
|
- `{"type": "room.join", "room": n}`: join. Answer `{"type": "room.joined", "room": n, "chat": chatId,
|
||||||
|
"participants": [{"imvu_id", "seat"}], "sequence": s}` (the room's latest message number), or
|
||||||
|
`{"type": "room.error", "room": n, "reason": "full" | "closed" | "adult" | "not_found" | "not_linked" | "too_many",
|
||||||
|
"message": "..."}` (`too_many`: the session is in 5 hosted rooms already). Joining again (a reconnect) answers the
|
||||||
|
same. A newcomer's seat is 0 until it sends `room.seat`. `maxUsers` counts IMVU accounts, so the same account
|
||||||
|
joining from a second session isn't refused as full.
|
||||||
|
- `{"type": "room.leave", "room": n}`.
|
||||||
|
- `{"type": "room.seat", "room": n, "seat": k}`: the person sat down (`*seat k`); 0-99.
|
||||||
|
- `{"type": "room.send", "room": n, "mount": "messages" | "control", "message": "..."}` (up to 64 KB): to everyone
|
||||||
|
else in the room. For `messages` the hub checks the JSON's `userId` is the sender's IMVU account and drops it
|
||||||
|
otherwise (silently). Each session may send bursts of 20, then 10 a second; more is dropped. Malformed messages
|
||||||
|
(bad room, seat, mount, a message over 64 KB, sending to a room it isn't in) get `{"type": "error", "message"}`.
|
||||||
|
|
||||||
|
From the hub:
|
||||||
|
|
||||||
|
- `{"type": "room.message", "room": n, "from": imvuId, "mount": "...", "message": "...", "sequence": s}`: numbered
|
||||||
|
per room, from 1.
|
||||||
|
- `{"type": "room.participants", "room": n, "participants": [{"imvu_id", "seat"}]}`: whenever someone joins, leaves
|
||||||
|
or sits (not to the one joining: `room.joined` has the list).
|
||||||
|
- `{"type": "room.closed", "room": n}`: the room closed, or this session is out of it: its IMVU account was unlinked
|
||||||
|
or it sent `session` with another account, or the room became adult and its account isn't. (A lower `maxUsers`
|
||||||
|
sends no one out; it only stops newcomers.)
|
||||||
|
|
||||||
|
A session closing leaves its rooms. The hub keeps the participants and message numbers in memory; messages aren't
|
||||||
|
stored (design.md: chat retention).
|
||||||
|
|
||||||
|
## The proxy's part
|
||||||
|
|
||||||
|
- **XML-RPC** for hosted IDs, answered by the proxy (never sent to IMVU): `test.getRoomCardInfo`,
|
||||||
|
`chat.getOrMakeChat` (activity `publicroom-2100000000-<n>`: joins, answers `chatId`, `seat`, `lastMessageId`,
|
||||||
|
`participantUrl`), `test.getRoomState` (the scene, with community products as local IDs), `chat.getParticipants`,
|
||||||
|
`chat.terminateChat`, `test.setRoomState` (owners: saved with `PUT .../scene`).
|
||||||
|
- **IMQ**: subscribing to `/chat/<chatId>` of a hosted room is answered by the proxy (result, `JoinedQueue`,
|
||||||
|
`CreateMount`s for `control`, `messages`, `participants`); `SendMessage` on it goes to the hub as `room.send`, and
|
||||||
|
`room.message` comes back as `G2cSendMessage`. `inv:/room/room-2100000000-<n>` is answered with a result and a
|
||||||
|
`node` mount, and nothing more.
|
||||||
|
- **Room list**: hosted rooms are added to `rooms_list_paginated.php`'s answer, and `room_occupancy/2100000000-<n>`
|
||||||
|
queues are answered from `room.participants`.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE "hosted_room" (
|
||||||
|
"n" serial PRIMARY KEY NOT NULL,
|
||||||
|
"owner_id" text NOT NULL,
|
||||||
|
"owner_imvu_id" bigint NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text DEFAULT '' NOT NULL,
|
||||||
|
"room_pid" bigint NOT NULL,
|
||||||
|
"rating" text DEFAULT 'general' NOT NULL,
|
||||||
|
"max_users" integer DEFAULT 10 NOT NULL,
|
||||||
|
"scene" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"scene_hash" text DEFAULT '' NOT NULL,
|
||||||
|
"closed" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "hosted_room" ADD CONSTRAINT "hosted_room_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "hosted_room_owner" ON "hosted_room" USING btree ("owner_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "hosted_room_created" ON "hosted_room" USING btree ("created_at");
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -36,6 +36,13 @@
|
|||||||
"when": 1793347080470,
|
"when": 1793347080470,
|
||||||
"tag": "0004_parent_info",
|
"tag": "0004_parent_info",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793355138984,
|
||||||
|
"tag": "0005_hosted_rooms",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import * as schema from "./schema";
|
import * as schema from "./schema";
|
||||||
|
|
||||||
export { schema };
|
export { schema };
|
||||||
export type { FileEntry, ParentInfo } from "./schema";
|
export type { FileEntry, ParentInfo, SceneItem } from "./schema";
|
||||||
export * from "drizzle-orm";
|
export * from "drizzle-orm";
|
||||||
|
|
||||||
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
|
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
|
||||||
|
|||||||
@@ -331,3 +331,33 @@ export const library = pgTable(
|
|||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.userId, t.productId] })],
|
(t) => [primaryKey({ columns: [t.userId, t.productId] })],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---- T5DE-hosted rooms (docs/hosted-rooms.md) ----
|
||||||
|
|
||||||
|
/** A scene item, as the client's room_contents: [furniture instance ID, product ID (IMVU's or the hub's), placement]. */
|
||||||
|
export type SceneItem = [number, number | string, string];
|
||||||
|
|
||||||
|
// A room hosted by the hub: its instance ID is "2100000000-<n>", its chat ID 2100000000 + n. Who's in it is kept in
|
||||||
|
// memory only (apps/api/src/realtime.ts). Closing it keeps the row, so its number is never reused.
|
||||||
|
export const hostedRoom = pgTable(
|
||||||
|
"hosted_room",
|
||||||
|
{
|
||||||
|
n: serial("n").primaryKey(),
|
||||||
|
ownerId: text("owner_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
// One of the owner's linked IMVU accounts, shown as the room's owner (the client lets it set the scene).
|
||||||
|
ownerImvuId: bigint("owner_imvu_id", { mode: "number" }).notNull(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
description: text("description").notNull().default(""), // plain text
|
||||||
|
roomPid: bigint("room_pid", { mode: "number" }).notNull(), // the room shell, an IMVU product
|
||||||
|
rating: text("rating").notNull().default("general"), // "general", "adult"
|
||||||
|
maxUsers: integer("max_users").notNull().default(10),
|
||||||
|
scene: jsonb("scene").$type<SceneItem[]>().notNull().default([]),
|
||||||
|
sceneHash: text("scene_hash").notNull().default(""),
|
||||||
|
closed: boolean("closed").notNull().default(false),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
updatedAt: updatedAt(),
|
||||||
|
},
|
||||||
|
(t) => [index("hosted_room_owner").on(t.ownerId), index("hosted_room_created").on(t.createdAt)],
|
||||||
|
);
|
||||||
Reference in new issue
Block a user