Hosted rooms: the hub's side
CI / test (push) Successful in 1m5s

Rooms made on the website (hosted_room, migration 0005), the REST routes in
docs/hosted-rooms.md, and the realtime room.* protocol: joining with the
capacity, adult and linked-account rules, seats, numbered messages with
forged senders dropped and a rate limit, closing. The Rooms page lists them,
with a form to make one and the owner's edit and close.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-30 03:21:21 -07:00
1 parent 0c67232425
commit 2a1fc94df6
18 files changed
+3138 -52

No files matched your search

+125 -1
View File
@@ -11,6 +11,7 @@ import { Realtime } from "./realtime";
import { isAdultRoom, Rooms } from "./rooms";
import { Signer } from "./keys";
import { ProductError, Products } from "./products";
import { chatId, HostedError, HostedRooms, instanceId, maxPlacement, maxSceneItems } from "./hosted";
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 products = new Products(db, signer, imvu);
const rooms = roomsService ?? new Rooms(db, imvu);
const hosted = new HostedRooms(db);
const realtime =
rt ??
new Realtime({
@@ -89,6 +91,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
room: (instanceId) => {
rooms.seen(instanceId).catch((e) => console.error("rooms:", e));
},
hostedRoom: (n) => hosted.info(n),
});
const limiter = new Limiter();
@@ -210,6 +213,41 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
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 ownLink = async (p: Person, imvuId: number) => {
@@ -565,6 +603,92 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
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.
.post(
"/catalogs",
@@ -1015,7 +1139,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 };
return { app, realtime, age, rooms, products, signer, hosted };
}
export type App = ReturnType<typeof createApp>["app"];