// The hub's API: Better Auth at /api/auth, the hub's own routes at /api/v1. The website calls it through its own // origin (Next.js rewrites /api to here), so session cookies are first-party; T5DE's proxy calls it with a bearer // token. import { Elysia, t } from "elysia"; import { and, desc, eq, ilike, inArray, ne, or, sql, count, schema, type Database, type ParentInfo } from "@t5de/db"; import type { Auth } from "./auth"; import { AgeError, AgeService } from "./age"; import { imvuAgeProvider, imvuResult } from "./age/imvu"; import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu"; 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"; import { DecorationError, Decorations, maxBatch, maxDecorationPlacement, maxDecorations } from "./decorations"; const { user, session, imvuLink, linkChallenge, audit } = schema; export type Deps = { db: Database; auth: Auth; imvu: ImvuClient; realtime?: Realtime; age?: AgeService; rooms?: Rooms; /** The hub's API URL, the issuer of its tokens. */ apiURL?: string; }; /** An error shown to the person: the website displays message as is. */ export class Problem extends Error { constructor( readonly status: number, readonly slug: string, message: string, ) { super(message); } } type Person = typeof user.$inferSelect; type Link = typeof imvuLink.$inferSelect; const roles = ["user", "moderator", "admin"] as const; const isModerator = (p: Person) => p.role === "moderator" || p.role === "admin"; // Link codes: "t5de-" and eight letters and digits without look-alikes. function linkCode(): string { const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const bytes = crypto.getRandomValues(new Uint8Array(8)); const s = [...bytes].map((b) => alphabet[b % alphabet.length]).join(""); return `t5de-${s.slice(0, 4)}-${s.slice(4)}`; } function randomID(bytes = 16): string { return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString("base64url"); } const challengeLifetime = 24 * 60 * 60 * 1000; /** Allows n attempts per key in a window (IMVU lookups, which cost IMVU a request each). */ class Limiter { private hits = new Map(); allow(key: string, n: number, windowMs: number): boolean { const now = Date.now(); const recent = (this.hits.get(key) ?? []).filter((t) => now - t < windowMs); if (recent.length >= n) { this.hits.set(key, recent); return false; } recent.push(now); this.hits.set(key, recent); if (this.hits.size > 100_000) this.hits.clear(); return true; } } export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms: roomsService, apiURL }: Deps) { const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]); 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 decorations = new Decorations(db); const realtime = rt ?? new Realtime({ owner: async (imvuId) => { const [l] = await db.select({ userId: imvuLink.userId }).from(imvuLink).where(eq(imvuLink.imvuId, imvuId)); return l?.userId; }, adult: async (userId, imvuId) => (await age.adultAccounts(userId, [imvuId])).has(imvuId), room: (instanceId) => { rooms.seen(instanceId).catch((e) => console.error("rooms:", e)); }, hostedRoom: (n) => hosted.info(n), }); const limiter = new Limiter(); const personView = (p: Person) => ({ id: p.id, username: p.displayUsername ?? p.username ?? p.name, email: p.email, role: (roles as readonly string[]).includes(p.role) ? (p.role as (typeof roles)[number]) : "user", linkCap: p.linkCap, createdAt: p.createdAt.toISOString(), banned: p.banned === true, banReason: p.banReason ?? "", }); /** Links as the website shows them: with live presence, and age by the one rule. */ const linkViews = async (userId: string, links: Link[]) => { const ids = links.map((l) => l.imvuId); const adult = await age.adultAccounts(userId, ids); const imvuChecks = await age.accountChecks("imvu", ids); const online = realtime.online(); return links.map((l) => { const live = online.find((o) => o.imvuId === l.imvuId); const check = imvuChecks.get(l.imvuId); return { imvuId: l.imvuId, username: l.username, linkedAt: l.linkedAt.toISOString(), ageVerified: check?.method === "imvu_age_verified", accessPass: check?.method === "imvu_access_pass", adult: adult.has(l.imvuId), checkedAt: (check?.checkedAt ?? l.linkedAt).toISOString(), showBadge: l.showBadge, showPresence: l.showPresence, listRooms: l.listRooms, online: live !== undefined, outfit: live?.outfit ?? [], }; }); }; const linkView = async (l: Link) => (await linkViews(l.userId, [l]))[0]!; const audited = (actorId: string | null, action: string, target: string, detail: Record = {}) => db.insert(audit).values({ actorId, action, target, detail }); /** Who's online that viewer may see: accounts showing their presence (moderators see all). */ const visibleOnline = async (viewer: Person | undefined) => { const online = realtime.online(); if (online.length === 0) return []; const links = await db.select().from(imvuLink).where(inArray(imvuLink.imvuId, online.map((o) => o.imvuId))); const mod = viewer !== undefined && isModerator(viewer); const adult = new Set(); if (mod) { for (const l of links) if ((await age.adultAccounts(l.userId, [l.imvuId])).has(l.imvuId)) adult.add(l.imvuId); } return online.flatMap((o) => { const l = links.find((l) => l.imvuId === o.imvuId); if (!l || (!l.showPresence && !mod)) return []; return [ { imvuId: o.imvuId, username: l.username, badge: l.showBadge, since: o.since.toISOString(), outfit: o.outfit, ...(mod ? { hidden: !l.showPresence, personId: o.userId, adult: adult.has(o.imvuId) } : {}), }, ]; }); }; const imvuIdParam = t.Object({ imvu: t.Numeric({ minimum: 1 }) }); /** Whether any of a person's IMVU accounts counts as adult (for seeing adult products and rooms). */ const personAdult = async (p: Person | undefined) => { if (!p) return false; const ids = (await db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.userId, p.id))).map((l) => l.id); return ids.length > 0 && (await age.adultAccounts(p.id, ids)).size > 0; }; /** Runs a product operation, turning its errors into Problems. */ const productCall = async (f: () => Promise): Promise => { try { return await f(); } catch (e) { if (e instanceof ProductError) throw new Problem(e.status, e.slug, e.message); throw e; } }; /** `latest`: the latest revision's parent and its info (the image and categories are the parent's). */ const productView = ( p: typeof schema.product.$inferSelect, creator?: string | null, creatorUsername?: string | null, latest?: { parent: number | null; parentInfo: ParentInfo | null } | null, ) => ({ id: p.id, name: p.name, description: p.description, rating: p.rating as "general" | "adult", latest: p.latest, creator: creator ?? creatorUsername ?? "", creatorUsername: creatorUsername ?? "", parent: latest?.parent ?? 0, image: latest?.parentInfo?.image ?? null, categories: latest?.parentInfo?.categories ?? [], createdAt: p.createdAt.toISOString(), updatedAt: p.updatedAt.toISOString(), }); const catalogView = (c: typeof schema.catalog.$inferSelect) => ({ id: c.id, name: c.name, url: c.url, contact: c.contact, visibility: c.visibility, membership: c.membership, status: c.status, createdAt: c.createdAt.toISOString(), }); /** Runs a hosted-room operation, turning its errors into Problems. */ const hostedCall = async (f: () => Promise): Promise => { 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 }), ]); /** Runs a decorations operation, turning its errors into Problems. */ const decorationCall = async (f: () => Promise): Promise => { try { return await f(); } catch (e) { if (e instanceof DecorationError) throw new Problem(e.status, e.slug, e.message); throw e; } }; const decorationItem = t.Tuple([ t.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }), t.String({ pattern: "^[a-z2-7]{10}$" }), t.String({ maxLength: maxDecorationPlacement }), ]); const productParam = t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }) }); const ownLink = async (p: Person, imvuId: number) => { const [l] = await db.select().from(imvuLink).where(and(eq(imvuLink.imvuId, imvuId), eq(imvuLink.userId, p.id))); if (!l) throw new Problem(404, "not_found", "That account isn't linked to you."); return l; }; const imvuCall = async (p: Person, call: () => Promise): Promise => { if (!limiter.allow(`imvu:${p.id}`, 20, 60 * 60 * 1000)) throw new Problem(429, "rate_limited", "Too many lookups. Try again in a while."); try { return await call(); } catch (e) { if (e instanceof NoSuchImvuUser) throw new Problem(404, "no_such_imvu_account", "IMVU has no account by that name."); if (e instanceof ImvuUnavailable) throw new Problem(502, "imvu_unavailable", "IMVU didn't answer. Try again in a moment."); throw e; } }; const personById = async (id: string) => { const [p] = await db.select().from(user).where(eq(user.id, id)); if (!p) throw new Problem(404, "not_found", "No such person."); return p; }; const api = new Elysia({ prefix: "/api/v1" }) .error({ Problem }) .onError(({ code, error, set }) => { if (code === "Problem") { set.status = error.status; return { error: error.slug, message: error.message }; } if (code === "VALIDATION") { set.status = 400; return { error: "invalid", message: "Some of that isn't right. Check it and try again." }; } if (code === "NOT_FOUND") { set.status = 404; return { error: "not_found", message: "Not found." }; } }) .derive(async ({ request }) => { const s = await auth.api.getSession({ headers: request.headers }); const person = s?.user ? ((await db.select().from(user).where(eq(user.id, s.user.id)))[0] ?? undefined) : undefined; return { person: person && !person.banned ? person : undefined, sessionId: s?.session.id }; }) .macro({ signedIn: { resolve: ({ person }) => { if (!person) throw new Problem(401, "signed_out", "Sign in first."); return { me: person }; }, }, moderator: { resolve: ({ person }) => { if (!person) throw new Problem(401, "signed_out", "Sign in first."); if (!isModerator(person)) throw new Problem(403, "forbidden", "Only moderators can do this."); return { me: person }; }, }, }) // Who's signed in, and the home page. .get("/me", ({ me }) => ({ person: personView(me) }), { signedIn: true }) .get("/home", async ({ person }) => { const online = (await visibleOnline(person)).length; if (!person) return { onlineCount: online }; const [row] = await db.select({ n: count() }).from(imvuLink).where(eq(imvuLink.userId, person.id)); return { onlineCount: online, links: row?.n ?? 0 }; }) // Who's online. .get("/online", async ({ person }) => ({ online: await visibleOnline(person) })) .get("/online/events", ({ person, request }) => { const encoder = new TextEncoder(); let stop = () => {}; const stream = new ReadableStream({ start(controller) { const send = async () => { const online = await visibleOnline(person); try { controller.enqueue(encoder.encode(`event: online\ndata: ${JSON.stringify({ online })}\n\n`)); } catch {} }; const unsubscribe = realtime.subscribe(() => void send()); const keepAlive = setInterval(() => { try { controller.enqueue(encoder.encode(": keep-alive\n\n")); } catch {} }, 25_000); stop = () => { unsubscribe(); clearInterval(keepAlive); }; request.signal.addEventListener("abort", () => { stop(); try { controller.close(); } catch {} }); void send(); }, cancel() { stop(); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" }, }); }) // Your account. .get( "/account", async ({ me }) => { const links = await db.select().from(imvuLink).where(eq(imvuLink.userId, me.id)).orderBy(imvuLink.linkedAt); const challenges = await db .select() .from(linkChallenge) .where(and(eq(linkChallenge.userId, me.id), sql`${linkChallenge.createdAt} > now() - interval '1 day'`)) .orderBy(linkChallenge.createdAt); const [computers] = await db .select({ n: count() }) .from(session) .where(and(eq(session.userId, me.id), ilike(session.userAgent, "t5de-proxy%"), sql`${session.expiresAt} > now()`)); return { person: personView(me), links: await linkViews(me.id, links), challenges: challenges.map((c) => ({ imvuId: c.imvuId, username: c.username, code: c.code, createdAt: c.createdAt.toISOString(), expiresAt: new Date(c.createdAt.getTime() + challengeLifetime).toISOString(), })), computers: computers?.n ?? 0, canLink: links.length < me.linkCap, }; }, { signedIn: true }, ) .post( "/account/links", async ({ me, body, set }) => { const found = await imvuCall(me, () => imvu.byUsername(body.username)); const [existing] = await db.select().from(imvuLink).where(eq(imvuLink.imvuId, found.id)); if (existing?.userId === me.id) throw new Problem(409, "already_linked", `${found.username} is already linked.`); const code = linkCode(); const [c] = await db .insert(linkChallenge) .values({ userId: me.id, imvuId: found.id, username: found.username, code }) .onConflictDoUpdate({ target: [linkChallenge.userId, linkChallenge.imvuId], set: { code, username: found.username, createdAt: new Date() }, }) .returning(); set.status = 201; return { challenge: { imvuId: c!.imvuId, username: c!.username, code: c!.code, createdAt: c!.createdAt.toISOString(), expiresAt: new Date(c!.createdAt.getTime() + challengeLifetime).toISOString(), }, }; }, { signedIn: true, body: t.Object({ username: t.String({ minLength: 1, maxLength: 64 }) }) }, ) .post( "/account/links/:imvu/check", async ({ me, params }) => { const [c] = await db .select() .from(linkChallenge) .where( and( eq(linkChallenge.userId, me.id), eq(linkChallenge.imvuId, params.imvu), sql`${linkChallenge.createdAt} > now() - interval '1 day'`, ), ); if (!c) throw new Problem(410, "expired", "That code expired. Start again."); const found = await imvuCall(me, () => imvu.byId(params.imvu)); if (!found.tagline.toLowerCase().includes(c.code.toLowerCase())) throw new Problem( 409, "code_missing", `${found.username}'s tagline doesn't have the code yet. IMVU can take a minute to show a new tagline.`, ); const result = await db.transaction(async (tx) => { const [{ n } = { n: 0 }] = await tx .select({ n: count() }) .from(imvuLink) .where(and(eq(imvuLink.userId, me.id), ne(imvuLink.imvuId, found.id))); if (n >= me.linkCap) throw new Problem(403, "link_cap", `You can link up to ${me.linkCap} IMVU accounts. Ask a moderator if you need more.`); const [prev] = await tx.select().from(imvuLink).where(eq(imvuLink.imvuId, found.id)); const moved = prev !== undefined && prev.userId !== me.id; const values = { userId: me.id, username: found.username }; let link: Link; if (!prev) { [link] = (await tx.insert(imvuLink).values({ imvuId: found.id, pairwiseId: randomID(), ...values }).returning()) as [Link]; } else if (moved) { // A moved account gets a new pairwise ID and starts with nothing shown: nothing connects the new owner // to the old one. [link] = (await tx .update(imvuLink) .set({ ...values, pairwiseId: randomID(), linkedAt: new Date(), showBadge: false, showPresence: false }) .where(eq(imvuLink.imvuId, found.id)) .returning()) as [Link]; } else { [link] = (await tx.update(imvuLink).set(values).where(eq(imvuLink.imvuId, found.id)).returning()) as [Link]; } await tx.delete(linkChallenge).where(eq(linkChallenge.imvuId, found.id)); await tx.insert(audit).values({ actorId: me.id, action: "link.add", target: String(found.id), detail: moved ? { imvu: found.username, movedFrom: prev!.userId } : { imvu: found.username }, }); return { link, moved, previousOwner: prev?.userId }; }); // Age from IMVU, for this account; a moved account's earlier checks belonged to its previous owner. if (result.moved) await age.forgetAccount(found.id); await age.record({ userId: me.id, imvuId: found.id }, "imvu", imvuResult(found)); if (result.moved) { await rooms.forgetOwner(found.id); realtime.unlinked(result.previousOwner!, found.id); } await realtime.linked(me.id, found.id); return { link: await linkView(result.link), moved: result.moved }; }, { signedIn: true, params: imvuIdParam }, ) .delete( "/account/challenges/:imvu", async ({ me, params, set }) => { await db.delete(linkChallenge).where(and(eq(linkChallenge.userId, me.id), eq(linkChallenge.imvuId, params.imvu))); set.status = 204; }, { signedIn: true, params: imvuIdParam }, ) .patch( "/account/links/:imvu", async ({ me, params, body }) => { await ownLink(me, params.imvu); const [l] = await db .update(imvuLink) .set({ ...(body.showBadge !== undefined ? { showBadge: body.showBadge } : {}), ...(body.showPresence !== undefined ? { showPresence: body.showPresence } : {}), ...(body.listRooms !== undefined ? { listRooms: body.listRooms } : {}), }) .where(eq(imvuLink.imvuId, params.imvu)) .returning(); return { link: await linkView(l!) }; }, { signedIn: true, params: imvuIdParam, body: t.Object({ showBadge: t.Optional(t.Boolean()), showPresence: t.Optional(t.Boolean()), listRooms: t.Optional(t.Boolean()), }), }, ) .post( "/account/links/:imvu/recheck", async ({ me, params }) => { await ownLink(me, params.imvu); const found = await imvuCall(me, () => imvu.byId(params.imvu)); await age.record({ userId: me.id, imvuId: params.imvu }, "imvu", imvuResult(found)); const [l] = await db.update(imvuLink).set({ username: found.username }).where(eq(imvuLink.imvuId, params.imvu)).returning(); return { link: await linkView(l!) }; }, { signedIn: true, params: imvuIdParam }, ) .delete( "/account/links/:imvu", async ({ me, params, set }) => { const l = await ownLink(me, params.imvu); await db.delete(imvuLink).where(eq(imvuLink.imvuId, l.imvuId)); await age.forgetAccount(l.imvuId); await rooms.forgetOwner(l.imvuId); realtime.unlinked(me.id, l.imvuId); await audited(me.id, "link.remove", String(l.imvuId), { imvu: l.username }); set.status = 204; }, { signedIn: true, params: imvuIdParam }, ) .post( "/account/signout-computers", async ({ me, set }) => { await db.delete(session).where(and(eq(session.userId, me.id), ilike(session.userAgent, "t5de-proxy%"))); realtime.disconnect(me.id); set.status = 204; }, { signedIn: true }, ) // A proxy's sign-in code, approved by the person on the website. .post( "/device/approve", async ({ me, body, request, set }) => { const raw = body.code.toUpperCase().replace(/[\s-]/g, ""); try { // Viewing the code claims it for this person; then they approve it. await auth.api.deviceVerify({ query: { user_code: raw }, headers: request.headers }); await auth.api.deviceApprove({ body: { userCode: raw }, headers: request.headers }); } catch { throw new Problem(404, "not_found", "That code isn't right, or it expired. Check the code T5DE shows."); } await audited(me.id, "proxy.signin", ""); set.status = 204; }, { signedIn: true, body: t.Object({ code: t.String({ minLength: 4, maxLength: 32 }) }) }, ) // IMVU rooms whose owners list them, with the T5DE users in each now (those showing they're online). .get("/rooms", async ({ person: me }) => { const viewerAdult = await personAdult(me); const listed = (await rooms.listed()).filter((r) => viewerAdult || !isAdultRoom(r.room)); const here = new Map(); for (const o of realtime.online()) for (const r of o.rooms) here.set(r, [...(here.get(r) ?? []), o.imvuId]); const ids = [...new Set([...here.values()].flat())]; const shown = ids.length ? await db .select({ imvuId: imvuLink.imvuId, username: imvuLink.username, badge: imvuLink.showBadge }) .from(imvuLink) .where(and(inArray(imvuLink.imvuId, ids), eq(imvuLink.showPresence, true))) : []; const out = listed.map(({ room: r, owner }) => { const t5de = shown.filter((u) => here.get(r.instanceId)?.includes(u.imvuId)); return { instanceId: r.instanceId, name: r.name, description: r.description, imageURL: r.imageURL, owner: { imvuId: r.ownerImvuId, username: owner }, adult: isAdultRoom(r), occupancy: r.occupancy, capacity: r.capacity, url: `https://go.imvu.com/chat/room-${r.instanceId}`, t5de, seenAt: r.seenAt.toISOString(), }; }); out.sort((a, b) => b.t5de.length - a.t5de.length); 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 }, ) // Decorations over IMVU rooms (docs/products.md, "Decorations"). .get( "/decorations", async ({ person, query }) => { const instances = [...new Set((query.instances ?? "").split(",").filter(Boolean))]; if (instances.length > maxBatch) throw new Problem(400, "invalid", `Up to ${maxBatch} rooms at a time.`); return { rooms: await decorationCall(async () => decorations.get(instances, await personAdult(person))) }; }, { query: t.Object({ instances: t.Optional(t.String({ maxLength: 1024 })) }) }, ) .get( "/decorations/:instance", async ({ person, params }) => (await decorationCall(async () => decorations.get([params.instance], await personAdult(person))))[0]!, { params: t.Object({ instance: t.String({ maxLength: 32 }) }) }, ) .put( "/decorations/:instance", async ({ me, params, body }) => { if (!limiter.allow(`decor:${me.id}`, 60, 60 * 1000)) throw new Problem(429, "rate_limited", "Too many changes. Try again in a moment."); await decorationCall(() => decorations.set(me.id, params.instance, body.contents)); realtime.decorated(params.instance); return (await decorations.get([params.instance], await personAdult(me)))[0]!; }, { signedIn: true, params: t.Object({ instance: t.String({ maxLength: 32 }) }), body: t.Object({ contents: t.Array(decorationItem, { maxItems: maxDecorations }) }), }, ) // Catalogs: registering one, and the directory. .post( "/catalogs", async ({ me, body, set }) => { const r = await productCall(() => products.registerCatalog(me.id, body)); await audited(me.id, "catalog.register", r.catalog.id, { name: r.catalog.name, url: r.catalog.url }); set.status = 201; return { catalog: catalogView(r.catalog), key: r.key }; }, { signedIn: true, body: t.Object({ name: t.String({ maxLength: 64 }), url: t.String({ maxLength: 300 }), contact: t.String({ maxLength: 200 }), visibility: t.String(), membership: t.String(), }), }, ) .get("/catalogs", async ({ person }) => ({ catalogs: (await products.catalogs(person?.id)).map((c) => ({ ...catalogView(c), mine: c.ownerId === person?.id })), })) // For catalogs: registering an uploaded revision. .post( "/catalog/revisions", async ({ request, body, set }) => { const c = await products.catalogFor(request.headers.get("authorization")); if (!c) throw new Problem(401, "forbidden", "Not an approved catalog."); const r = await productCall(() => products.registerRevision(c, body)); set.status = 201; return r; }, { body: t.Object({ uploadToken: t.String({ maxLength: 4000 }), files: t.Array(t.Object({ name: t.String(), sha256: t.String(), size: t.Number() }), { maxItems: 512 }), rating: t.String(), parent: t.Number(), name: t.String({ maxLength: 200 }), notes: t.String({ maxLength: 4000 }), }), }, ) // Products. .post( "/products/upload", async ({ me, body }) => productCall(() => products.uploadToken(me.id, body.catalog, body.product)), { signedIn: true, body: t.Object({ catalog: t.String(), product: t.Optional(t.String()) }) }, ) .get( "/products", async ({ person, query }) => { const adult = await personAdult(person); const rows = await products.list({ ...query, adult }); return { products: rows.map((r) => productView(r.product, r.creator, r.creatorUsername, r)) }; }, { query: t.Object({ q: t.Optional(t.String({ maxLength: 100 })), creator: t.Optional(t.String({ maxLength: 64 })), category: t.Optional(t.Numeric({ minimum: 1, maximum: 2_147_483_647, multipleOf: 1 })), limit: t.Optional(t.Numeric({ minimum: 1, maximum: 200, multipleOf: 1 })), }), }, ) .get( "/products/:id", async ({ person, params }) => { const adult = await personAdult(person); const p = await productCall(() => products.get(params.id, adult)); const lib = person ? await products.inLibrary(person.id, params.id) : undefined; const latest = p.revisions.find((r) => r.number === p.product.latest); return { product: productView(p.product, p.creator, p.creatorUsername, latest), revisions: p.revisions.map((r) => ({ number: r.number, rating: r.rating, notes: r.notes, retracted: r.retracted, files: r.files, size: r.files.reduce((n, f) => n + f.size, 0), createdAt: r.createdAt.toISOString(), catalogs: p.hosts.filter((h) => h.number === r.number).map((h) => h.name), })), inLibrary: lib !== undefined, stayOn: lib?.stayOn ?? null, mine: person?.id === p.product.creatorId, }; }, { params: productParam }, ) .patch( "/products/:id", async ({ me, params, body }) => { const p = await productCall(() => products.update(me.id, params.id, body)); await audited(me.id, "product.edit", p.id, { fields: Object.keys(body) }); return { product: productView(p, undefined, undefined, await products.latestParent(p)) }; }, { signedIn: true, params: productParam, body: t.Object({ name: t.Optional(t.String({ maxLength: 64 })), description: t.Optional(t.String({ maxLength: 20000 })), rating: t.Optional(t.Union([t.Literal("general"), t.Literal("adult")])), }), }, ) .post( "/products/:id/revisions/:n/retract", async ({ me, params }) => { await productCall(() => products.retract(me.id, params.id, params.n)); await audited(me.id, "product.retract", params.id, { revision: params.n }); return { ok: true }; }, { signedIn: true, params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), n: t.Numeric({ minimum: 1 }) }) }, ) // What the proxy loads: the revision this person uses, where to get it, and the hub's word on its files. .get( "/products/:id/files", async ({ person, params }) => { const r = await productCall(async () => products.revisionFor(person?.id, params.id, await personAdult(person))); return { product: r.product.id, revision: r.revision.number, rating: r.revision.rating, parent: r.revision.parent, name: r.product.name, creator: r.creator ?? "", files: r.revision.files, catalogs: r.catalogs, token: r.revision.token, }; }, { params: productParam }, ) .post( "/access", async ({ me }) => ({ token: await signer.sign("t5de-access", { sub: me.id, adult: await personAdult(me) }, 60 * 60), }), { signedIn: true }, ) // Libraries. .get( "/library", async ({ me }) => ({ library: (await products.library(me.id)).map((l) => ({ product: productView(l.product, l.creator, undefined, l), stayOn: l.stayOn, addedAt: l.addedAt.toISOString(), })), }), { signedIn: true }, ) .put( "/library/:id", async ({ me, params }) => { await productCall(async () => products.add(me.id, params.id, await personAdult(me))); realtime.notify(me.id, { type: "library" }); return { ok: true }; }, { signedIn: true, params: productParam }, ) .delete( "/library/:id", async ({ me, params, set }) => { await products.remove(me.id, params.id); realtime.notify(me.id, { type: "library" }); set.status = 204; }, { signedIn: true, params: productParam }, ) .patch( "/library/:id", async ({ me, params, body }) => { await productCall(() => products.stayOn(me.id, params.id, body.stayOn)); realtime.notify(me.id, { type: "library" }); return { ok: true }; }, { signedIn: true, params: productParam, body: t.Object({ stayOn: t.Union([t.Integer({ minimum: 1 }), t.Null()]) }), }, ) // Age verification beyond IMVU's: the providers in use, and the person's own checks. .get( "/age", async ({ me }) => ({ providers: age.list(), checks: (await age.personChecks(me.id)).map((c) => ({ provider: c.provider, method: c.method, status: c.status, overEighteen: c.overEighteen, createdAt: c.createdAt.toISOString(), checkedAt: c.checkedAt?.toISOString() ?? null, expiresAt: c.expiresAt?.toISOString() ?? null, })), }), { signedIn: true }, ) .post( "/age/:provider/start", async ({ me, params, body }) => { let p; try { p = age.provider(params.provider); } catch { throw new Problem(404, "not_found", "That way of verifying age isn't available."); } if (p.scope !== "person") throw new Problem(400, "invalid", "That one checks an IMVU account: link it instead."); const started = await age.start({ userId: me.id }, p.id, body.returnURL); return started.kind === "done" ? { kind: "done" as const } : started; }, { signedIn: true, params: t.Object({ provider: t.String() }), body: t.Object({ returnURL: t.String({ maxLength: 500 }) }) }, ) .post( "/age/:provider/complete", async ({ params, body }) => { const p = age.provider(params.provider); if (!p.complete) throw new Problem(404, "not_found", "Not found."); try { const c = await age.finish(p.id, body.ref, await p.complete(body.ref, body.response)); return { status: c.status, overEighteen: c.overEighteen }; } catch (e) { if (e instanceof AgeError) throw new Problem(404, "not_found", "That check isn't waiting for an answer."); throw e; } }, { signedIn: true, params: t.Object({ provider: t.String() }), body: t.Object({ ref: t.String(), response: t.Unknown() }) }, ) // Providers' webhooks: each checks the request is really from it. .post( "/age/webhook/:provider", async ({ params, request, set }) => { let p; try { p = age.provider(params.provider); } catch { throw new Problem(404, "not_found", "Not found."); } if (!p.webhook) throw new Problem(404, "not_found", "Not found."); let answer; try { answer = await p.webhook(request); } catch { throw new Problem(401, "forbidden", "Not from the provider."); } try { await age.finish(p.id, answer.ref, answer.result); } catch (e) { if (!(e instanceof AgeError)) throw e; // already finished: webhooks can repeat } set.status = 204; }, { params: t.Object({ provider: t.String() }), parse: "none" }, ) // The console. .get( "/console/catalogs", async () => ({ catalogs: (await products.allCatalogs()).map(catalogView) }), { moderator: true }, ) .post( "/console/catalogs/:id/:decision", async ({ me, params }) => { const c = await productCall(() => products.setCatalogStatus(params.id, params.decision === "approve" ? "approved" : "rejected"), ); await audited(me.id, `catalog.${params.decision}`, c.id, { name: c.name }); return { catalog: catalogView(c) }; }, { moderator: true, params: t.Object({ id: t.String(), decision: t.Union([t.Literal("approve"), t.Literal("reject")]) }) }, ) .get( "/console", async ({ me }) => { const [counts] = await db .select({ people: sql`(select count(*) from ${user})::int`, links: sql`(select count(*) from ${imvuLink})::int`, computers: sql`(select count(*) from ${session} where ${session.userAgent} ilike 't5de-proxy%' and ${session.expiresAt} > now())::int`, }) .from(sql`(select 1) as one`); const log = await db .select({ at: audit.at, actor: user.username, action: audit.action, target: audit.target, detail: audit.detail }) .from(audit) .leftJoin(user, eq(user.id, audit.actorId)) .orderBy(desc(audit.id)) .limit(50); return { counts: counts!, connections: realtime.connections(), online: await visibleOnline(me), audit: log.map((e) => ({ ...e, at: e.at.toISOString(), actor: e.actor ?? "" })), }; }, { moderator: true }, ) .get( "/console/people", async ({ query }) => { const q = (query.q ?? "").trim(); const like = `%${q.replace(/[%_\\]/g, (c) => "\\" + c)}%`; const rows = await db .select() .from(user) .where( q ? or( ilike(user.username, like), inArray(user.id, db.select({ id: imvuLink.userId }).from(imvuLink).where(ilike(imvuLink.username, like))), ) : undefined, ) .orderBy(desc(user.createdAt)) .limit(100); return { people: rows.map(personView) }; }, { moderator: true, query: t.Object({ q: t.Optional(t.String({ maxLength: 64 })) }) }, ) .get( "/console/people/:id", async ({ params }) => { const p = await personById(params.id); const links = await db.select().from(imvuLink).where(eq(imvuLink.userId, p.id)).orderBy(imvuLink.linkedAt); const [computers] = await db .select({ n: count() }) .from(session) .where(and(eq(session.userId, p.id), ilike(session.userAgent, "t5de-proxy%"), sql`${session.expiresAt} > now()`)); return { person: personView(p), links: await linkViews(p.id, links), computers: computers?.n ?? 0 }; }, { moderator: true }, ) .post( "/console/people/:id/link-cap", async ({ me, params, body }) => { const p = await personById(params.id); const [updated] = await db.update(user).set({ linkCap: body.linkCap }).where(eq(user.id, p.id)).returning(); await audited(me.id, "person.link_cap", p.username ?? p.id, { from: p.linkCap, to: body.linkCap }); return { person: personView(updated!) }; }, { moderator: true, body: t.Object({ linkCap: t.Integer({ minimum: 0, maximum: 1000 }) }) }, ) .post( "/console/people/:id/ban", async ({ me, params, body }) => { const p = await personById(params.id); if (p.id === me.id) throw new Problem(400, "invalid", "You can't ban yourself."); if (isModerator(p) && me.role !== "admin") throw new Problem(403, "forbidden", "Only admins can ban moderators."); const [updated] = await db.update(user).set({ banned: true, banReason: body.reason }).where(eq(user.id, p.id)).returning(); await db.delete(session).where(eq(session.userId, p.id)); realtime.disconnect(p.id); await audited(me.id, "person.ban", p.username ?? p.id, { reason: body.reason }); return { person: personView(updated!) }; }, { moderator: true, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) }, ) .post( "/console/people/:id/unban", async ({ me, params }) => { const p = await personById(params.id); const [updated] = await db.update(user).set({ banned: false, banReason: null }).where(eq(user.id, p.id)).returning(); await audited(me.id, "person.unban", p.username ?? p.id); return { person: personView(updated!) }; }, { moderator: true }, ) .post( "/console/people/:id/role", async ({ me, params, body }) => { if (me.role !== "admin") throw new Problem(403, "forbidden", "Only admins can change roles."); const p = await personById(params.id); if (p.id === me.id) throw new Problem(400, "invalid", "Another admin has to change your role."); const [updated] = await db.update(user).set({ role: body.role }).where(eq(user.id, p.id)).returning(); await audited(me.id, "person.role", p.username ?? p.id, { from: p.role, to: body.role }); return { person: personView(updated!) }; }, { moderator: true, body: t.Object({ role: t.Union([t.Literal("user"), t.Literal("moderator"), t.Literal("admin")]) }), }, ) // T5DE's proxy: which of the IMVU accounts it's showing get the T5 badge (linked, the badge turned on, the // person not banned). Nothing else about them. .post( "/badges", async ({ me, body }) => { if (!limiter.allow(`badges:${me.id}`, 120, 60 * 1000)) throw new Problem(429, "rate_limited", "Too many lookups. Try again in a while."); const ids = [...new Set(body.imvuIds)]; if (ids.length === 0) return { badges: [] }; const rows = await db .select({ imvuId: imvuLink.imvuId }) .from(imvuLink) .innerJoin(user, eq(user.id, imvuLink.userId)) .where(and(inArray(imvuLink.imvuId, ids), eq(imvuLink.showBadge, true), sql`coalesce(${user.banned}, false) = false`)); return { badges: rows.map((r) => r.imvuId) }; }, { signedIn: true, body: t.Object({ imvuIds: t.Array(t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), { maxItems: 200 }), }), }, ) // T5DE's proxy: the live connection. .ws("/realtime", { async beforeHandle({ person, set }) { if (!person) { set.status = 401; return { error: "signed_out", message: "Sign in first." }; } }, open(ws) { const { person } = ws.data as unknown as { person: Person }; realtime.open( ws.id, person.id, () => ws.close(4001, "signed out"), (m) => ws.send(JSON.stringify(m)), ); ws.send(JSON.stringify({ type: "welcome" })); }, async message(ws, message) { const reply = await realtime.message(ws.id, message); if (reply) ws.send(JSON.stringify(reply)); }, close(ws) { realtime.close(ws.id); }, }); const app = new Elysia() .all("/api/auth/*", ({ request }) => auth.handler(request)) .get("/.well-known/jwks.json", async () => auth.api.getJwks()) .get("/.well-known/t5de-keys.json", async () => signer.jwks()) .get("/health", () => ({ ok: true })) .use(api); return { app, realtime, age, rooms, products, signer, hosted, decorations }; } export type App = ReturnType["app"];