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 { 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"];
|
||||
Reference in new issue
Block a user