diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 9c2859c..e511d6a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -8,10 +8,11 @@ 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"; const { user, session, imvuLink, linkChallenge, audit } = schema; -export type Deps = { db: Database; auth: Auth; imvu: ImvuClient; realtime?: Realtime; age?: AgeService }; +export type Deps = { db: Database; auth: Auth; imvu: ImvuClient; realtime?: Realtime; age?: AgeService; rooms?: Rooms }; /** An error shown to the person: the website displays message as is. */ export class Problem extends Error { @@ -61,13 +62,20 @@ class Limiter { } } -export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Deps) { +export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms: roomsService }: Deps) { const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]); + const rooms = roomsService ?? new Rooms(db, imvu); const realtime = rt ?? - new Realtime(async (imvuId) => { - const [l] = await db.select({ userId: imvuLink.userId }).from(imvuLink).where(eq(imvuLink.imvuId, imvuId)); - return l?.userId; + 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)); + }, }); const limiter = new Limiter(); @@ -101,6 +109,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep checkedAt: (check?.checkedAt ?? l.linkedAt).toISOString(), showBadge: l.showBadge, showPresence: l.showPresence, + listRooms: l.listRooms, online: live !== undefined, outfit: live?.outfit ?? [], }; @@ -365,8 +374,11 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep // 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) realtime.unlinked(result.previousOwner!, found.id); - realtime.linked(me.id, found.id); + 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 }, @@ -388,6 +400,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep .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(); @@ -396,7 +409,11 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep { signedIn: true, params: imvuIdParam, - body: t.Object({ showBadge: t.Optional(t.Boolean()), showPresence: t.Optional(t.Boolean()) }), + body: t.Object({ + showBadge: t.Optional(t.Boolean()), + showPresence: t.Optional(t.Boolean()), + listRooms: t.Optional(t.Boolean()), + }), }, ) .post( @@ -416,6 +433,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep 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; @@ -450,6 +468,46 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep { 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 = + me !== undefined && + ( + await age.adultAccounts( + me.id, + (await db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.userId, me.id))).map((l) => l.id), + ) + ).size > 0; + 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 }; + }) + // Age verification beyond IMVU's: the providers in use, and the person's own checks. .get( "/age", @@ -645,7 +703,12 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep }, open(ws) { const { person } = ws.data as unknown as { person: Person }; - realtime.open(ws.id, person.id, () => ws.close(4001, "signed out")); + 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) { @@ -663,7 +726,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Dep .get("/health", () => ({ ok: true })) .use(api); - return { app, realtime, age }; + return { app, realtime, age, rooms }; } export type App = ReturnType["app"]; diff --git a/apps/api/src/imvu.ts b/apps/api/src/imvu.ts index 5b0e7ed..72be4c7 100644 --- a/apps/api/src/imvu.ts +++ b/apps/api/src/imvu.ts @@ -1,5 +1,6 @@ -// IMVU's public user API: who an account is, its tagline (for linking) and whether it's age verified or has Access -// Pass. The hub calls it rarely (linking, and age checks) and depends on it for nothing else. +// IMVU's public API: who an account is, its tagline (for linking) and whether it's age verified or has Access Pass; +// and rooms (for the rooms list). The hub calls it rarely (linking, age checks, rooms T5DE users enter) and depends +// on it for nothing else. export type ImvuUser = { id: number; @@ -9,6 +10,21 @@ export type ImvuUser = { accessPass: boolean; }; +export type ImvuRoom = { + instanceId: string; // "-" + ownerId: number; + name: string; + description: string; + imageURL: string; + privacy: string; // "public", ... + type: string; // "listed", "unlisted", ... + contentRating: string; // "GA", "AP" + accessPass: boolean; + occupancy: number; + capacity: number; + expired: boolean; +}; + export class NoSuchImvuUser extends Error { constructor() { super("no such IMVU account"); @@ -31,11 +47,22 @@ export class ImvuClient { byUsername(username: string): Promise { const name = username.trim(); if (!name || name.length > 64) return Promise.reject(new NoSuchImvuUser()); - return this.get(`/user?username=${encodeURIComponent(name)}`); + return this.get(`/user?username=${encodeURIComponent(name)}`).then(parse); } byId(id: number): Promise { - return this.get(`/user/user-${id}`); + return this.get(`/user/user-${id}`).then(parse); + } + + /** A room by its instance ID ("-"); undefined if there's no such room. */ + async room(instanceId: string): Promise { + if (!/^[0-9]{1,12}-[0-9]{1,12}$/.test(instanceId)) return undefined; + try { + return parseRoom(await this.get(`/room/room-${instanceId}`), instanceId); + } catch (e) { + if (e instanceof NoSuchImvuUser) return undefined; + throw e; + } } private async wait() { @@ -45,7 +72,7 @@ export class ImvuClient { if (at > now) await Bun.sleep(at - now); } - private async get(path: string): Promise { + private async get(path: string): Promise { await this.wait(); let response: Response; try { @@ -58,10 +85,36 @@ export class ImvuClient { } if (response.status === 404) throw new NoSuchImvuUser(); if (!response.ok) throw new ImvuUnavailable(`IMVU answered ${response.status}`); - return parse(await response.json()); + return response.json(); } } +export function parseRoom(answer: unknown, instanceId: string): ImvuRoom | undefined { + const den = (answer as { denormalized?: Record }> })?.denormalized ?? {}; + const entry = Object.entries(den).find(([key]) => key.endsWith(`/room/room-${instanceId}`)); + const d = entry?.[1].data; + if (!d) return undefined; + const str = (v: unknown) => (typeof v === "string" ? v : ""); + const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : 0); + let image = str(d.image_url); + if (image.startsWith("//")) image = "https:" + image; + if (!image.startsWith("https://")) image = ""; + return { + instanceId, + ownerId: num(d.customers_id) || Number(instanceId.split("-")[0]), + name: str(d.name).slice(0, 200), + description: str(d.description).slice(0, 2000), + imageURL: image, + privacy: str(d.privacy), + type: str(d.type), + contentRating: str(d.content_rating), + accessPass: d.is_ap === true, + occupancy: num(d.occupancy), + capacity: num(d.capacity), + expired: d.is_expired === true, + }; +} + // IMVU's "denormalized" answers: the user is the one /user/user- entry. export function parse(answer: unknown): ImvuUser { const den = (answer as { denormalized?: Record }> })?.denormalized ?? {}; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 6c53759..49cb773 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -22,11 +22,12 @@ const webURL = env.WEB_URL ?? "http://localhost:4100"; const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db"); const auth = createAuth(db, { apiURL, webURL, secret }); -const { app, age } = createApp({ db, auth, imvu: new ImvuClient() }); +const { app, age, rooms } = createApp({ db, auth, imvu: new ImvuClient() }); app.listen({ port, hostname: env.HOST ?? "0.0.0.0" }); // Age answers that can change (IMVU's) are checked again when they expire, a few at a time. setInterval(() => { age.recheckDue(50).catch((e) => console.error("age re-checks:", e)); + rooms.recheckDue(20).catch((e) => console.error("room re-checks:", e)); }, 10 * 60 * 1000); console.log(`t5de-hub API on ${apiURL} (website ${webURL})`); diff --git a/apps/api/src/realtime.ts b/apps/api/src/realtime.ts index 7d5fde7..d0c2d10 100644 --- a/apps/api/src/realtime.ts +++ b/apps/api/src/realtime.ts @@ -1,34 +1,69 @@ -// The live connection with signed-in proxies: which IMVU account each client is using (presence), and the -// community products it wears. Later, room channels. +// 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 +// community products). // // Protocol (JSON text messages over the WebSocket at /api/v1/realtime, authorized with the proxy's session token as // a bearer token): -// proxy -> hub {"type": "session", "imvu_id": 123} the client signed in as this IMVU account -// proxy -> hub {"type": "outfit", "items": [{"id", "name"}]} the community products it wears now +// proxy -> hub {"type": "session", "imvu_id": 123} the client signed in as this IMVU account +// proxy -> hub {"type": "outfit", "items": [{"id", "name", "adult"}]} the community products it wears now +// proxy -> hub {"type": "join", "chat": "1379947638", "room": "1-2"} the client is in this IMVU chat (room: the +// IMVU room instance, for a room's chat) +// proxy -> hub {"type": "leave", "chat": "1379947638"} +// hub -> proxy {"type": "chat", "chat": "...", "avatars": [{"imvu_id", "items"}]} +// the other T5DE users in that chat and what they wear; sent on join and on every change. Adult +// products only go to clients whose IMVU account counts as adult. // hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."} -export type Item = { id: string; name: string }; +export type Item = { id: string; name: string; adult: boolean }; -export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[] }; +export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[]; rooms: string[] }; // requested: the IMVU account the client said it's using, even before it's linked (it counts once it is). -type Session = { userId: string; imvuId: number; requested: number; since: Date; outfit: Item[]; close: () => void }; +// chats: IMVU chat ID -> room instance ("" for chats that aren't a room's). lastSent: per chat, what it was last told. +type Session = { + userId: string; + imvuId: number; + requested: number; + adult: boolean; + since: Date; + outfit: Item[]; + chats: Map; + lastSent: Map; + send: (message: object) => void; + close: () => void; +}; -export type Owners = (imvuId: number) => Promise; +export type RealtimeDeps = { + /** The person an IMVU account is linked to. */ + owner: (imvuId: number) => Promise; + /** Whether a person's IMVU account counts as adult (the age rule). */ + adult?: (userId: string, imvuId: number) => Promise; + /** A client entered an IMVU room (to learn about rooms for the rooms list). */ + room?: (instanceId: string) => void; +}; + +const maxChats = 16; +const chatPattern = /^[0-9]{1,20}$/; +const roomPattern = /^[0-9]{1,12}-[0-9]{1,12}$/; export class Realtime { private sessions = new Map(); private listeners = new Set<() => void>(); + private readonly deps: Required; - constructor(private readonly owner: Owners) {} + constructor(deps: RealtimeDeps | RealtimeDeps["owner"]) { + const d = typeof deps === "function" ? { owner: deps } : deps; + this.deps = { adult: async () => false, room: () => {}, ...d }; + } - /** Calls fn on every change of presence or outfits; returns a function that stops it. */ + /** Calls fn on every change of presence, outfits or rooms; returns a function that stops it. */ subscribe(fn: () => void): () => void { this.listeners.add(fn); return () => this.listeners.delete(fn); } - private changed() { + private changed(chats: Iterable = []) { + for (const chat of new Set(chats)) this.sync(chat); for (const fn of this.listeners) fn(); } @@ -39,7 +74,8 @@ export class Realtime { if (!s.imvuId) continue; const prev = byId.get(s.imvuId); if (prev && prev.since > s.since) continue; - byId.set(s.imvuId, { userId: s.userId, imvuId: s.imvuId, since: s.since, outfit: [...s.outfit] }); + const rooms = [...s.chats.values()].filter(Boolean); + byId.set(s.imvuId, { userId: s.userId, imvuId: s.imvuId, since: s.since, outfit: [...s.outfit], rooms }); } return [...byId.values()].sort((a, b) => a.since.getTime() - b.since.getTime()); } @@ -48,40 +84,57 @@ export class Realtime { return this.sessions.size; } - open(id: string, userId: string, close: () => void) { - this.sessions.set(id, { userId, imvuId: 0, requested: 0, since: new Date(), outfit: [], close }); + open(id: string, userId: string, close: () => void, send: (message: object) => void = () => {}) { + this.sessions.set(id, { + userId, + imvuId: 0, + requested: 0, + adult: false, + since: new Date(), + outfit: [], + chats: new Map(), + lastSent: new Map(), + send, + close, + }); } close(id: string) { const s = this.sessions.get(id); this.sessions.delete(id); - if (s?.imvuId) this.changed(); + if (s && (s.imvuId || s.chats.size)) this.changed(s.chats.keys()); } /** A person just linked an IMVU account: their clients already using it count now. */ - linked(userId: string, imvuId: number) { + async linked(userId: string, imvuId: number) { + const chats: string[] = []; let changed = false; for (const s of this.sessions.values()) { if (s.userId === userId && s.requested === imvuId && s.imvuId !== imvuId) { s.imvuId = imvuId; s.since = new Date(); + s.adult = await this.deps.adult(userId, imvuId); + chats.push(...s.chats.keys()); changed = true; } } - if (changed) this.changed(); + if (changed) this.changed(chats); } /** An IMVU account was unlinked (or moved): it no longer counts for that person's clients. */ unlinked(userId: string, imvuId: number) { + const chats: string[] = []; let changed = false; for (const s of this.sessions.values()) { if (s.userId === userId && s.imvuId === imvuId) { s.imvuId = 0; + s.adult = false; s.outfit = []; + chats.push(...s.chats.keys()); changed = true; } } - if (changed) this.changed(); + if (changed) this.changed(chats); } /** Closes a person's connections (banned, or signed out of their computers). */ @@ -89,27 +142,59 @@ export class Realtime { for (const s of this.sessions.values()) if (s.userId === userId) s.close(); } + /** Tells everyone in a chat about the other T5DE users there (only those it wasn't told already). */ + private sync(chat: string) { + const members = [...this.sessions.values()].filter((s) => s.chats.has(chat)); + for (const viewer of members) { + const avatars = new Map(); + for (const other of members) { + if (!other.imvuId || other.imvuId === viewer.imvuId) continue; + avatars.set( + other.imvuId, + other.outfit.filter((it) => !it.adult || viewer.adult), + ); + } + const message = { + type: "chat", + chat, + avatars: [...avatars].map(([imvuId, items]) => ({ imvu_id: imvuId, items })), + }; + const text = JSON.stringify(message); + if (viewer.lastSent.get(chat) === text) continue; + viewer.lastSent.set(chat, text); + viewer.send(message); + } + } + /** Handles a message from a proxy; returns the reply to send, if any. */ async message(id: string, raw: unknown): Promise { const s = this.sessions.get(id); if (!s) return; - const m = (typeof raw === "string" ? safeJSON(raw) : raw) as { type?: unknown; imvu_id?: unknown; items?: unknown }; + const m = (typeof raw === "string" ? safeJSON(raw) : raw) as { + type?: unknown; + imvu_id?: unknown; + items?: unknown; + chat?: unknown; + room?: unknown; + }; if (!m || typeof m !== "object") return { type: "error", message: "not a message" }; if (m.type === "session") { const imvuId = Number(m.imvu_id); s.requested = Number.isSafeInteger(imvuId) && imvuId > 0 ? imvuId : 0; - const owner = Number.isSafeInteger(imvuId) && imvuId > 0 ? await this.owner(imvuId) : undefined; + const owner = s.requested ? await this.deps.owner(imvuId) : undefined; if (owner !== s.userId) { // Not this person's account: no presence, no outfit for it. s.imvuId = 0; + s.adult = false; s.outfit = []; - this.changed(); + this.changed(s.chats.keys()); return { type: "error", message: "not_linked" }; } s.imvuId = imvuId; s.since = new Date(); s.outfit = []; - this.changed(); + s.adult = await this.deps.adult(s.userId, imvuId); + this.changed(s.chats.keys()); return; } if (m.type === "outfit") { @@ -119,8 +204,26 @@ export class Realtime { s.outfit = items .filter((it): it is Item => typeof it?.id === "string" && typeof it?.name === "string") .filter((it) => it.id && it.id.length <= 128 && it.name.length <= 128) - .map((it) => ({ id: it.id, name: it.name })); - this.changed(); + .map((it) => ({ id: it.id, name: it.name, adult: it.adult === true })); + this.changed(s.chats.keys()); + return; + } + if (m.type === "join" || m.type === "leave") { + const chat = typeof m.chat === "string" ? m.chat : String(m.chat ?? ""); + if (!chatPattern.test(chat)) return { type: "error", message: "bad chat" }; + if (m.type === "leave") { + if (!s.chats.delete(chat)) return; + s.lastSent.delete(chat); + this.changed([chat]); + return; + } + const room = typeof m.room === "string" && roomPattern.test(m.room) ? m.room : ""; + if (!s.chats.has(chat) && s.chats.size >= maxChats) return { type: "error", message: "too many chats" }; + s.chats.set(chat, room); + s.lastSent.delete(chat); // tell it again, even if nothing changed + if (s.imvuId) s.adult = await this.deps.adult(s.userId, s.imvuId); + if (room) this.deps.room(room); + this.changed([chat]); return; } return { type: "error", message: "unknown message type" }; diff --git a/apps/api/src/rooms.ts b/apps/api/src/rooms.ts new file mode 100644 index 0000000..eb34e2e --- /dev/null +++ b/apps/api/src/rooms.ts @@ -0,0 +1,111 @@ +// The rooms list: IMVU rooms owned by linked accounts that chose to list them. The hub learns of a room when a T5DE +// user enters it, and asks IMVU about it (owner, public and listed or not); it's checked again while it's in use, +// so a room made unlisted or closed drops off the list. +import { and, desc, eq, gt, lt, schema, type Database } from "@t5de/db"; +import type { ImvuClient } from "./imvu"; + +const { room, imvuLink } = schema; + +export type Room = typeof room.$inferSelect; + +const recheckAfter = 60 * 60 * 1000; +// Rooms nobody entered for this long aren't checked again or listed. +const forgetAfter = 30 * 24 * 60 * 60 * 1000; + +/** Whether IMVU says anyone may find and enter the room. */ +export const isListable = (r: Pick) => + r.privacy === "public" && r.type === "listed" && !r.expired; + +/** Whether the room is for adults (Access Pass rooms). */ +export const isAdultRoom = (r: Pick) => r.accessPass || r.contentRating === "AP"; + +export class Rooms { + private checking = new Set(); + + constructor( + private readonly db: Database, + private readonly imvu: ImvuClient, + ) {} + + /** A T5DE user entered a room: remember it if its owner is linked, asking IMVU about it if it's due. */ + async seen(instanceId: string) { + const ownerId = Number(instanceId.split("-")[0]); + const [owner] = await this.db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.imvuId, ownerId)); + if (!owner) return; + const [known] = await this.db.select().from(room).where(eq(room.instanceId, instanceId)); + if (known && Date.now() - known.checkedAt.getTime() < recheckAfter) { + await this.db.update(room).set({ seenAt: new Date() }).where(eq(room.instanceId, instanceId)); + return; + } + await this.check(instanceId); + } + + /** Asks IMVU about a room again, and records what it says (or forgets the room if it's gone). */ + async check(instanceId: string) { + if (this.checking.has(instanceId)) return; + this.checking.add(instanceId); + try { + const r = await this.imvu.room(instanceId); + if (!r || String(r.ownerId) !== instanceId.split("-")[0]) { + await this.db.delete(room).where(eq(room.instanceId, instanceId)); + return; + } + const values = { + ownerImvuId: r.ownerId, + name: r.name, + description: r.description, + imageURL: r.imageURL, + privacy: r.privacy, + type: r.type, + contentRating: r.contentRating, + accessPass: r.accessPass, + occupancy: r.occupancy, + capacity: r.capacity, + expired: r.expired, + checkedAt: new Date(), + seenAt: new Date(), + }; + await this.db + .insert(room) + .values({ instanceId, ...values }) + .onConflictDoUpdate({ target: room.instanceId, set: values }); + } finally { + this.checking.delete(instanceId); + } + } + + /** Checks again rooms in use whose answer is old (at most limit). */ + async recheckDue(limit = 20): Promise { + const due = await this.db + .select({ instanceId: room.instanceId }) + .from(room) + .where(and(lt(room.checkedAt, new Date(Date.now() - recheckAfter)), gt(room.seenAt, new Date(Date.now() - forgetAfter)))) + .limit(limit); + let n = 0; + for (const r of due) { + try { + await this.check(r.instanceId); + n++; + } catch { + // IMVU unavailable: try again next time. + } + } + return n; + } + + /** The rooms to list: listable, owner chose to list, entered lately; with the owner's IMVU name. */ + async listed() { + const rows = await this.db + .select({ room, owner: imvuLink.username }) + .from(room) + .innerJoin(imvuLink, eq(imvuLink.imvuId, room.ownerImvuId)) + .where(and(eq(imvuLink.listRooms, true), gt(room.seenAt, new Date(Date.now() - forgetAfter)))) + .orderBy(desc(room.seenAt)); + return rows.filter((r) => isListable(r.room)); + } + + /** Forgets an IMVU account's rooms (it was unlinked). */ + async forgetOwner(imvuId: number) { + await this.db.delete(room).where(eq(room.ownerImvuId, imvuId)); + } +} diff --git a/apps/api/test/hub.test.ts b/apps/api/test/hub.test.ts index 07f3be0..35e37d3 100644 --- a/apps/api/test/hub.test.ts +++ b/apps/api/test/hub.test.ts @@ -234,7 +234,7 @@ describe("T5DE's proxy", () => { await erin.call("PATCH", "/api/v1/account/links/77", { showPresence: true, showBadge: true }); const online = (await visitor.call("GET", "/api/v1/online")).json.online; expect(online).toEqual([ - { imvuId: 77, username: "ErinAv", badge: true, since: expect.any(String), outfit: [{ id: "blue-coat", name: "Blue Coat" }] }, + { imvuId: 77, username: "ErinAv", badge: true, since: expect.any(String), outfit: [{ id: "blue-coat", name: "Blue Coat", adult: false }] }, ]); // A ban closes the connection and ends the proxy's session. diff --git a/apps/api/test/rooms.test.ts b/apps/api/test/rooms.test.ts new file mode 100644 index 0000000..a633923 --- /dev/null +++ b/apps/api/test/rooms.test.ts @@ -0,0 +1,192 @@ +// The room channel (T5DE users in the same IMVU chat see each other's community products) and the rooms list. +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { openDatabase, schema, type Database } from "@t5de/db"; +import { createApp } from "../src/app"; +import { createAuth } from "../src/auth"; +import { ImvuClient, type ImvuUser } from "../src/imvu"; + +const webURL = "http://web.test"; +const accounts = new Map(); +const rooms = new Map>(); + +const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + const room = url.pathname.match(/^\/room\/room-(\d+-\d+)$/); + if (room) { + const r = rooms.get(room[1]!); + return Response.json({ + denormalized: r ? { [`https://api.imvu.com/room/room-${room[1]}`]: { data: r } } : {}, + }); + } + 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; + +const imvuRoom = (owner: number, n: number, over: Record = {}) => + rooms.set(`${owner}-${n}`, { + name: `Room ${n}`, + description: "hi", + type: "listed", + privacy: "public", + image_url: "//webasset-akm.imvu.com/room.gif", + occupancy: 2, + capacity: 10, + content_rating: "GA", + is_ap: false, + customers_id: owner, + is_expired: false, + ...over, + }); + +let db: Database; +let close: () => Promise; +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, res }; +} + +/** Signs a person up with one linked IMVU account; token is a session token for their proxy. */ +async function person(name: string, imvu: { id: number; ageVerified?: boolean }): Promise { + 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(""); + accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: !!imvu.ageVerified, accessPass: false }); + const started = await call(p, "POST", "/api/v1/account/links", { username: `${name}Av` }); + accounts.get(imvu.id)!.tagline = started.json.challenge.code; + expect((await call(p, "POST", `/api/v1/account/links/${imvu.id}/check`)).status).toBe(200); + return p; +} + +/** A proxy's live connection: everything the hub sent it, and the latest "chat" message per chat. */ +async function proxy(p: Person, imvuId: number) { + const ws = new WebSocket(`${base.replace("http", "ws")}/api/v1/realtime`, { headers: { Authorization: `Bearer ${p.token}` } } as any); + const got: any[] = []; + ws.onmessage = (e) => got.push(JSON.parse(String(e.data))); + await new Promise((r) => (ws.onopen = r)); + const send = (m: object) => ws.send(JSON.stringify(m)); + send({ type: "session", imvu_id: imvuId }); + return { + ws, + got, + send, + chat: (chat: string) => got.filter((m) => m.type === "chat" && m.chat === chat).at(-1), + }; +} + +async function waitFor(ok: () => boolean) { + for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(10); + expect(ok()).toBe(true); +} + +test("T5DE users in the same chat see each other's community products; adult ones only if adult", async () => { + const fiona = await person("fiona", { id: 101, ageVerified: true }); + const gus = await person("gus", { id: 102 }); + const f = await proxy(fiona, 101); + const g = await proxy(gus, 102); + f.send({ type: "outfit", items: [{ id: "coat", name: "Coat" }, { id: "lace", name: "Lace", adult: true }] }); + g.send({ type: "outfit", items: [{ id: "hat", name: "Hat" }] }); + + f.send({ type: "join", chat: "555", room: "101-1" }); + g.send({ type: "join", chat: "555", room: "101-1" }); + await waitFor(() => g.chat("555")?.avatars.length === 1 && f.chat("555")?.avatars.length === 1); + expect(g.chat("555").avatars).toEqual([{ imvu_id: 101, items: [{ id: "coat", name: "Coat", adult: false }] }]); + expect(f.chat("555").avatars).toEqual([{ imvu_id: 102, items: [{ id: "hat", name: "Hat", adult: false }] }]); + + // Outfit changes reach the others in the chat; a chat they're not in hears nothing. + g.send({ type: "join", chat: "777" }); + g.send({ type: "outfit", items: [] }); + await waitFor(() => f.chat("555")?.avatars[0]?.items.length === 0); + expect(f.chat("777")).toBeUndefined(); + + // Leaving (or disconnecting) takes them off the others' list. + g.send({ type: "leave", chat: "555" }); + await waitFor(() => f.chat("555")?.avatars.length === 0); + g.send({ type: "join", chat: "555" }); + await waitFor(() => f.chat("555")?.avatars.length === 1); + g.ws.close(); + await waitFor(() => f.chat("555")?.avatars.length === 0); + + // Nonsense is refused. + f.send({ type: "join", chat: "../x" }); + await waitFor(() => f.got.some((m) => m.message === "bad chat")); + f.ws.close(); +}); + +test("rooms are listed when public and listed, and their owner chose to list them", async () => { + const hana = await person("hana", { id: 201, ageVerified: true }); + const ivan = await person("ivan", { id: 202 }); + imvuRoom(201, 1); + imvuRoom(201, 2, { type: "unlisted" }); + imvuRoom(201, 3, { content_rating: "AP", is_ap: true }); + imvuRoom(201, 4, { privacy: "closed" }); + const h = await proxy(hana, 201); + for (const [chat, room] of [["1", "201-1"], ["2", "201-2"], ["3", "201-3"], ["4", "201-4"]]) h.send({ type: "join", chat, room }); + // A room whose owner isn't on T5DE is never stored. + imvuRoom(999, 1); + h.send({ type: "join", chat: "9", room: "999-1" }); + + const names = async (p?: Person) => ((await call(p, "GET", "/api/v1/rooms")).json.rooms as any[]).map((r) => r.name); + // The hub asks IMVU about each (spaced out); until Hana chooses to list them, none are listed. + for (let i = 0; i < 300 && (await db.select().from(schema.room)).length < 4; i++) await Bun.sleep(10); + expect((await db.select().from(schema.room)).map((r) => r.instanceId).sort()).toEqual(["201-1", "201-2", "201-3", "201-4"]); + expect(await names()).toEqual([]); + await call(hana, "PATCH", "/api/v1/account/links/201", { listRooms: true }); + expect(await names()).toEqual(["Room 1"]); + expect(await names(ivan)).toEqual(["Room 1"]); // Ivan isn't adult + expect((await names(hana)).sort()).toEqual(["Room 1", "Room 3"]); + + const [listed] = (await call(undefined, "GET", "/api/v1/rooms")).json.rooms; + expect(listed).toMatchObject({ + instanceId: "201-1", + description: "hi", // the website shows it as text + imageURL: "https://webasset-akm.imvu.com/room.gif", + owner: { imvuId: 201, username: "hanaAv" }, + url: "https://go.imvu.com/chat/room-201-1", + t5de: [], // Hana is there, but doesn't show she's online + }); + await call(hana, "PATCH", "/api/v1/account/links/201", { showPresence: true }); + expect((await call(undefined, "GET", "/api/v1/rooms")).json.rooms[0].t5de).toEqual([{ imvuId: 201, username: "hanaAv", badge: false }]); + + // Unlinking forgets the account's rooms. + await call(hana, "DELETE", "/api/v1/account/links/201"); + expect(await names(hana)).toEqual([]); + h.ws.close(); +}); diff --git a/apps/web/app/account/actions.ts b/apps/web/app/account/actions.ts index 7283def..0461bc7 100644 --- a/apps/web/app/account/actions.ts +++ b/apps/web/app/account/actions.ts @@ -43,7 +43,7 @@ export async function linkAction(_: ActionState, form: FormData): Promise { return attempt(async () => { unwrap(await api.account.links({ imvu }).patch(settings)); diff --git a/apps/web/app/account/link-card.tsx b/apps/web/app/account/link-card.tsx index a8632c0..ac8f550 100644 --- a/apps/web/app/account/link-card.tsx +++ b/apps/web/app/account/link-card.tsx @@ -27,7 +27,12 @@ export function LinkCard({ link }: { link: Link }) { - +
diff --git a/apps/web/app/account/link-settings.tsx b/apps/web/app/account/link-settings.tsx index e01cd08..a8470d6 100644 --- a/apps/web/app/account/link-settings.tsx +++ b/apps/web/app/account/link-settings.tsx @@ -6,7 +6,7 @@ import { Switch } from "@/components/ui/switch"; import type { ActionState } from "@/lib/actions"; import { saveSettings } from "./actions"; -type Settings = { showBadge: boolean; showPresence: boolean }; +type Settings = { showBadge: boolean; showPresence: boolean; listRooms: boolean }; export function LinkSettings({ imvuId, ...saved }: Settings & { imvuId: number }) { const [settings, setOptimistic] = useOptimistic(saved); @@ -29,6 +29,10 @@ export function LinkSettings({ imvuId, ...saved }: Settings & { imvuId: number } Show when I'm online in T5DE +
); diff --git a/apps/web/app/rooms/page.tsx b/apps/web/app/rooms/page.tsx new file mode 100644 index 0000000..1b3af5a --- /dev/null +++ b/apps/web/app/rooms/page.tsx @@ -0,0 +1,55 @@ +import type { Metadata } from "next"; +import { PageHeader, Tag } from "@/components/status"; +import { Card, CardContent } from "@/components/ui/card"; +import { api, unwrap } from "@/lib/api"; + +export const metadata: Metadata = { title: "Rooms" }; + +export default async function Rooms() { + const { rooms } = await api.rooms.get().then(unwrap); + return ( + <> + Public IMVU rooms of T5DE users. + {rooms.length === 0 ? ( + + +

No rooms listed yet.

+
+
+ ) : ( +
    + {rooms.map((r) => ( +
  • + + {r.imageURL && ( + // IMVU's own image of the room, from IMVU. + + )} + +
    + + {r.name} + + {r.adult && AP} + {r.t5de.length > 0 && ( + + {r.t5de.length} on T5DE + + )} +
    +

    + {r.owner.username} · {r.occupancy}/{r.capacity} +

    + {r.description &&

    {r.description}

    } + {r.t5de.length > 0 && ( +

    Here now: {r.t5de.map((u) => u.username).join(", ")}

    + )} +
    +
    +
  • + ))} +
+ )} + + ); +} diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx index 2c9cd2a..60d4c82 100644 --- a/apps/web/components/header.tsx +++ b/apps/web/components/header.tsx @@ -25,6 +25,7 @@ export async function Header() { diff --git a/docs/api.md b/docs/api.md index f2c3872..1ef39e3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -29,7 +29,7 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco | `POST /api/auth/device/code` | `{client_id: "t5de-proxy"}` → `{device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in}` (RFC 8628). `verification_uri` is the website's `/device`. | | `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 | -| `GET /api/v1/realtime` | WebSocket, bearer token. Protocol in `apps/api/src/realtime.ts`. | +| `GET /api/v1/realtime` | WebSocket, bearer token: presence, outfits, and the room channel. Protocol in `apps/api/src/realtime.ts`. | | `GET /.well-known/jwks.json` | the Ed25519 keys JWTs are signed with | ## Website routes (signed in unless noted) @@ -39,11 +39,12 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco | `GET /api/v1/home` | (anyone) `{onlineCount, links?}` | | `GET /api/v1/online` | (anyone) `{online}`: accounts showing they're online (moderators see all, with `hidden`) | | `GET /api/v1/online/events` | (anyone) server-sent events: `online` with `{online}` now and on each change | +| `GET /api/v1/rooms` | (anyone) `{rooms}`: listed IMVU rooms of T5DE users, with the T5DE users in each now (AP rooms for adults only) | | `GET /api/v1/account` | `{person, links, challenges, computers, canLink}` | | `POST /api/v1/account/links` | `{username}` → 201 `{challenge}` (the tagline code) | | `POST /api/v1/account/links/:imvu/check` | `{link, moved}` | | `DELETE /api/v1/account/challenges/:imvu` | cancel linking | -| `PATCH /api/v1/account/links/:imvu` | `{showBadge?, showPresence?}` → `{link}` | +| `PATCH /api/v1/account/links/:imvu` | `{showBadge?, showPresence?, listRooms?}` → `{link}` | | `POST /api/v1/account/links/:imvu/recheck` | `{link}` (age checked with IMVU again) | | `DELETE /api/v1/account/links/:imvu` | unlink | | `POST /api/v1/account/signout-computers` | signs T5DE out on all their computers | diff --git a/docs/design.md b/docs/design.md index 64aefaf..dfffa79 100644 --- a/docs/design.md +++ b/docs/design.md @@ -243,6 +243,20 @@ number, `productdata///`) but hardly shows them; T5DE shows them o 3. Other T5DE users' proxies add the items to that user's incoming `*use`, filtered by their own age settings. Non-T5DE users see the default. +How it's built (2026-10-29): +- The proxy sees the client's IMQ subscriptions to chat queues (`/chat/`) and tells the hub which chats it's + in; for a room's chat, also the room (from `chat.getOrMakeChat`, `activity: publicroom--`). +- The hub tells each proxy in a chat about the other T5DE users there and what they wear (adult products only to + clients whose account counts as adult), on joining and on every change. +- The proxy delivers `*use` / `*remove` lines from those users to the client (IMQ messages of its own, without a + sequence number, which the client doesn't track), and adds their community products to the whole outfits they + send (`*use` with the body, re-sent on joins and every 30 s or so), which would otherwise put IMVU's back. +- Products the viewer has no pack for are skipped (logged), until catalogs serve them. +- The client also posts its outfit to IMVU's chat participant endpoint (`legacy_outfit_message`); the proxy takes + community products out of it and re-signs it. +- Anyone signed in can say they're in a chat; what they learn is what T5DE users in it wear, which anyone in the + chat sees anyway. + Saved outfits are per IMVU account (avatars differ). The library (products added, favorites, followed creators) is per person, shared across their accounts. @@ -257,6 +271,12 @@ is per person, shared across their accounts. - **Chat retention** (**Decided**): not stored by default. When someone reports a conversation, the recent messages around the report are kept as evidence for 30 days, then deleted. - Room shells and furniture are ordinary products. +- **Rooms list** (2026-10-29, the user's request): the hub's Rooms page lists IMVU rooms of T5DE users that are + public, listed and not expired on IMVU (`privacy: public`, `type: listed`), and whose owner turned on "List my + public rooms" for that IMVU account (off by default, like the badge). IMVU's public API can't list a user's rooms, + so the hub learns of a room when a T5DE user enters it, asks IMVU about it, and asks again hourly while it's + used; rooms nobody entered for 30 days drop off. AP rooms are shown only to people with an adult account. Each + shows the T5DE users in it now who show they're online. ## Presence and badge (**Decided**: both opt-in, per IMVU account) @@ -363,9 +383,10 @@ The proxy streams and adds no waiting. ## Status - Phase 1 (local packs) done: t5de-api, t5de-proxy `internal/products`. -- Phase 2 (hub identity) under way: accounts, tagline linking, age, link caps, device sign-in, presence and badge, - console. Not yet: periodic age re-checks (on demand only), a sign-in button in the client (the proxy's `signin` - command for now), deployment. +- 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 + in the client (the proxy's `signin` command for now), deployment, hosted rooms. The room channel is tested with + 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 Postgres, Next.js website with Tailwind and shadcn/ui (README). It replaces the first version, in Go. Where this document says Go or templ for the hub, read that; catalogs are still to be decided (a Bun program compiled to one diff --git a/packages/db/drizzle/0002_rooms.sql b/packages/db/drizzle/0002_rooms.sql new file mode 100644 index 0000000..29faeeb --- /dev/null +++ b/packages/db/drizzle/0002_rooms.sql @@ -0,0 +1,19 @@ +CREATE TABLE "room" ( + "instance_id" text PRIMARY KEY NOT NULL, + "owner_imvu_id" bigint NOT NULL, + "name" text NOT NULL, + "description" text DEFAULT '' NOT NULL, + "image_url" text DEFAULT '' NOT NULL, + "privacy" text NOT NULL, + "type" text NOT NULL, + "content_rating" text DEFAULT '' NOT NULL, + "access_pass" boolean DEFAULT false NOT NULL, + "occupancy" integer DEFAULT 0 NOT NULL, + "capacity" integer DEFAULT 0 NOT NULL, + "expired" boolean DEFAULT false NOT NULL, + "checked_at" timestamp with time zone DEFAULT now() NOT NULL, + "seen_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "imvu_link" ADD COLUMN "list_rooms" boolean DEFAULT false NOT NULL;--> statement-breakpoint +CREATE INDEX "room_owner" ON "room" USING btree ("owner_imvu_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0002_snapshot.json b/packages/db/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..391b1b7 --- /dev/null +++ b/packages/db/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1128 @@ +{ + "id": "77d34434-f3e7-4015-ac31-6333eb46bed0", + "prevId": "6311568f-0471-47a9-8199-229d365d3dc4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.age_check": { + "name": "age_check", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "over_eighteen": { + "name": "over_eighteen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "age_check_user": { + "name": "age_check_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "age_check_imvu": { + "name": "age_check_imvu", + "columns": [ + { + "expression": "imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "age_check_provider_ref": { + "name": "age_check_provider_ref", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "age_check_user_id_user_id_fk": { + "name": "age_check_user_id_user_id_fk", + "tableFrom": "age_check", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit": { + "name": "audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "detail": { + "name": "detail", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "audit_at": { + "name": "audit_at", + "columns": [ + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_actor_id_user_id_fk": { + "name": "audit_actor_id_user_id_fk", + "tableFrom": "audit", + "tableTo": "user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_code_user_id_user_id_fk": { + "name": "device_code_user_id_user_id_fk", + "tableFrom": "device_code", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.imvu_link": { + "name": "imvu_link", + "schema": "", + "columns": { + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pairwise_id": { + "name": "pairwise_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "show_badge": { + "name": "show_badge", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_presence": { + "name": "show_presence", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "list_rooms": { + "name": "list_rooms", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "imvu_link_user": { + "name": "imvu_link_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "imvu_link_user_id_user_id_fk": { + "name": "imvu_link_user_id_user_id_fk", + "tableFrom": "imvu_link", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "imvu_link_pairwise_id_unique": { + "name": "imvu_link_pairwise_id_unique", + "nullsNotDistinct": false, + "columns": [ + "pairwise_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link_challenge": { + "name": "link_challenge", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "link_challenge_user_id_user_id_fk": { + "name": "link_challenge_user_id_user_id_fk", + "tableFrom": "link_challenge", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "link_challenge_user_id_imvu_id_pk": { + "name": "link_challenge_user_id_imvu_id_pk", + "columns": [ + "user_id", + "imvu_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.room": { + "name": "room", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_imvu_id": { + "name": "owner_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "privacy": { + "name": "privacy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_rating": { + "name": "content_rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "access_pass": { + "name": "access_pass", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "occupancy": { + "name": "occupancy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expired": { + "name": "expired", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "room_owner": { + "name": "room_owner", + "columns": [ + { + "expression": "owner_imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user": { + "name": "session_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "link_cap": { + "name": "link_cap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + } + }, + "indexes": { + "user_username_lower": { + "name": "user_username_lower", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier": { + "name": "verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 85bd4c1..06c9340 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1793277687785, "tag": "0001_age_checks", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1793321456968, + "tag": "0002_rooms", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index db362ec..4feb049 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -139,10 +139,34 @@ export const imvuLink = pgTable( linkedAt: timestamp("linked_at", { withTimezone: true }).notNull().defaultNow(), showBadge: boolean("show_badge").notNull().default(false), showPresence: boolean("show_presence").notNull().default(false), + listRooms: boolean("list_rooms").notNull().default(false), }, (t) => [index("imvu_link_user").on(t.userId)], ); +// An IMVU room a T5DE user entered whose owner is a linked account, as IMVU last described it. Listed on the hub +// only if its owner chose to list their rooms and IMVU says it's public and listed. +export const room = pgTable( + "room", + { + instanceId: text("instance_id").primaryKey(), // "-" + ownerImvuId: bigint("owner_imvu_id", { mode: "number" }).notNull(), + name: text("name").notNull(), + description: text("description").notNull().default(""), + imageURL: text("image_url").notNull().default(""), + privacy: text("privacy").notNull(), + type: text("type").notNull(), + contentRating: text("content_rating").notNull().default(""), + accessPass: boolean("access_pass").notNull().default(false), + occupancy: integer("occupancy").notNull().default(0), + capacity: integer("capacity").notNull().default(0), + expired: boolean("expired").notNull().default(false), + checkedAt: timestamp("checked_at", { withTimezone: true }).notNull().defaultNow(), + seenAt: timestamp("seen_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [index("room_owner").on(t.ownerImvuId)], +); + // An age check by one provider: of a person, or of one of their linked IMVU accounts. Only the outcome: never a // birth date, document, photo or name (providers that see those keep them). export const ageCheck = pgTable(