From f65f36c1ea4c28b4ee88e6bd0d31f815a2b6a63b Mon Sep 17 00:00:00 2001 From: dhkatz Date: Fri, 30 Oct 2026 03:58:42 -0700 Subject: [PATCH] Decorations over IMVU rooms: the hub's side T5 products placed in ordinary IMVU rooms, kept per room instance (room_decoration, migration 0006). GET /api/v1/decorations/:instance (and ?instances= for up to 20) gives what the viewer may see, with a hash; PUT is for the room owner's T5DE person only, checks the products exist, and tells every realtime session with a chat in that room ({"type": "decor"}). Hosted rooms' IDs are refused. Co-Authored-By: Claude Opus 5.5 --- apps/api/src/app.ts | 52 +- apps/api/src/decorations.ts | 107 ++ apps/api/src/realtime.ts | 9 + apps/api/test/decorations.test.ts | 239 +++ docs/api.md | 18 +- docs/design.md | 4 +- docs/products.md | 17 + packages/db/drizzle/0006_decorations.sql | 8 + packages/db/drizzle/meta/0006_snapshot.json | 1849 +++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/index.ts | 2 +- packages/db/src/schema.ts | 19 + 12 files changed, 2325 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/decorations.ts create mode 100644 apps/api/test/decorations.test.ts create mode 100644 packages/db/drizzle/0006_decorations.sql create mode 100644 packages/db/drizzle/meta/0006_snapshot.json diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ff9de58..d34b4bc 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -12,6 +12,7 @@ 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; @@ -80,6 +81,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms 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({ @@ -248,6 +250,22 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms 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) => { @@ -689,6 +707,38 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms { 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", @@ -1139,7 +1189,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms .get("/health", () => ({ ok: true })) .use(api); - return { app, realtime, age, rooms, products, signer, hosted }; + return { app, realtime, age, rooms, products, signer, hosted, decorations }; } export type App = ReturnType["app"]; diff --git a/apps/api/src/decorations.ts b/apps/api/src/decorations.ts new file mode 100644 index 0000000..5803ef1 --- /dev/null +++ b/apps/api/src/decorations.ts @@ -0,0 +1,107 @@ +// Decorations over IMVU rooms (docs/products.md, "Decorations"): T5 products an IMVU room's owner placed in it, +// kept here because IMVU only keeps the room without them. The routes are in app.ts; the notice to proxies in the +// room is realtime.ts's decorated(). +import { createHash } from "node:crypto"; +import { and, eq, inArray, ne, sql, schema, type Database, type DecorationItem } from "@t5de/db"; +import { hostedBase } from "./realtime"; + +const { roomDecoration, product, productRevision, imvuLink } = schema; + +export const maxDecorations = 300; +export const maxDecorationPlacement = 200; +export const maxBatch = 20; + +/** An IMVU room instance: "-". */ +export const decorationInstance = /^[1-9][0-9]{0,11}-[1-9][0-9]{0,9}$/; + +/** An error for the person; the routes turn it into a Problem. */ +export class DecorationError extends Error { + constructor( + readonly status: number, + readonly slug: string, + message: string, + ) { + super(message); + } +} + +export const decorationHash = (contents: DecorationItem[]) => + createHash("sha256").update(JSON.stringify(contents)).digest("hex"); + +/** The room's owner (its IMVU account), after checking the instance ID; hosted rooms are refused. */ +function ownerOf(instance: string): number { + if (!decorationInstance.test(instance)) throw new DecorationError(400, "invalid", "That isn't an IMVU room."); + const owner = Number(instance.slice(0, instance.indexOf("-"))); + if (owner >= hostedBase) throw new DecorationError(400, "hosted_room", "Hosted rooms keep their own scene."); + return owner; +} + +export type DecorationView = { instance: string; contents: DecorationItem[]; hash: string }; + +export class Decorations { + constructor(private readonly db: Database) {} + + /** + * Of these product IDs, those a viewer may see: registered (a revision exists), with a revision that isn't + * retracted, and general unless the viewer counts as adult. + */ + private async visible(ids: string[], adult: boolean): Promise> { + if (ids.length === 0) return new Set(); + const rows = await this.db + .select({ id: product.id }) + .from(product) + .where( + and( + inArray(product.id, ids), + ne(product.latest, 0), + adult ? undefined : eq(product.rating, "general"), + sql`exists (select 1 from ${productRevision} where ${productRevision.productId} = ${product.id} and not ${productRevision.retracted})`, + ), + ); + return new Set(rows.map((r) => r.id)); + } + + /** Rooms' decorations as a viewer sees them (in the order asked; rooms without any have an empty list). */ + async get(instances: string[], adult: boolean): Promise { + for (const i of instances) ownerOf(i); + const rows = instances.length + ? await this.db.select().from(roomDecoration).where(inArray(roomDecoration.instanceId, instances)) + : []; + const stored = new Map(rows.map((r) => [r.instanceId, r.contents])); + const ok = await this.visible([...new Set(rows.flatMap((r) => r.contents.map((it) => it[1])))], adult); + return instances.map((instance) => { + const contents = (stored.get(instance) ?? []).filter((it) => ok.has(it[1])); + return { instance, contents, hash: decorationHash(contents) }; + }); + } + + /** Sets a room's decorations (an empty list removes them): only one of the room owner's T5DE people may. */ + async set(personId: string, instance: string, contents: DecorationItem[]): Promise { + const owner = ownerOf(instance); + const [link] = await this.db + .select() + .from(imvuLink) + .where(and(eq(imvuLink.imvuId, owner), eq(imvuLink.userId, personId))); + if (!link) throw new DecorationError(403, "forbidden", "Only the room's owner can decorate it."); + if (contents.length > maxDecorations) + throw new DecorationError(400, "invalid", `A room holds up to ${maxDecorations} decorations.`); + if (new Set(contents.map((it) => it[0])).size !== contents.length) + throw new DecorationError(400, "invalid", "Each furniture instance ID can be used once."); + const ids = [...new Set(contents.map((it) => it[1]))]; + if (ids.length) { + const known = await this.db + .select({ id: product.id }) + .from(product) + .where(and(inArray(product.id, ids), ne(product.latest, 0))); + if (known.length !== ids.length) throw new DecorationError(400, "unknown_product", "Some of those products don't exist."); + } + if (contents.length === 0) { + await this.db.delete(roomDecoration).where(eq(roomDecoration.instanceId, instance)); + return; + } + await this.db + .insert(roomDecoration) + .values({ instanceId: instance, ownerImvuId: owner, contents }) + .onConflictDoUpdate({ target: roomDecoration.instanceId, set: { contents, updatedAt: new Date() } }); + } +} diff --git a/apps/api/src/realtime.ts b/apps/api/src/realtime.ts index 58a1f60..f574bb2 100644 --- a/apps/api/src/realtime.ts +++ b/apps/api/src/realtime.ts @@ -12,6 +12,9 @@ // 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": "decor", "room": "1-2"} +// that IMVU room's decorations changed (GET /api/v1/decorations/1-2 again); sent to every session +// with a chat in that room // hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."} // // Hosted rooms (n: the room's number; its chat ID is 2100000000 + n): @@ -198,6 +201,12 @@ export class Realtime { for (const s of this.sessions.values()) if (s.userId === userId) s.send(message); } + /** An IMVU room's decorations changed: every session in one of its chats is told, so its proxy fetches them. */ + decorated(instance: string) { + const message = { type: "decor", room: instance }; + for (const s of this.sessions.values()) if ([...s.chats.values()].includes(instance)) s.send(message); + } + /** Closes a person's connections (banned, or signed out of their computers). */ disconnect(userId: string) { for (const s of this.sessions.values()) if (s.userId === userId) s.close(); diff --git a/apps/api/test/decorations.test.ts b/apps/api/test/decorations.test.ts new file mode 100644 index 0000000..639829c --- /dev/null +++ b/apps/api/test/decorations.test.ts @@ -0,0 +1,239 @@ +// Decorations over IMVU rooms (docs/products.md, "Decorations"): the REST routes and the realtime notice, with fake +// proxies. +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { eq, 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 fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + const u = [...accounts.values()].find( + (u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(), + ); + return Response.json({ + denormalized: u + ? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } } + : {}, + }); +}) as typeof fetch; + +let db: Database; +let close: () => Promise; +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; id: string }; + +async function call(p: Person | undefined, method: string, path: string, body?: unknown) { + const res = await fetch(base + path, { + method, + headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}) }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + return { status: res.status, json: text ? JSON.parse(text) : undefined }; +} + +/** Signs a person up with linked IMVU accounts (adult: age verified on IMVU). */ +async function person(name: string, ...imvu: { id: number; ageVerified?: boolean }[]): Promise { + 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") ?? "", + id: "", + }; + expect(p.token).not.toBe(""); + p.id = (await call(p, "GET", "/api/v1/me")).json.person.id; + for (const [i, a] of imvu.entries()) { + const username = `${name}Av${i || ""}`; + accounts.set(a.id, { id: a.id, username, tagline: "", ageVerified: !!a.ageVerified, accessPass: false }); + const started = await call(p, "POST", "/api/v1/account/links", { username }); + accounts.get(a.id)!.tagline = started.json.challenge.code; + expect((await call(p, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200); + } + return p; +} + +/** A registered product with one revision (retracted: that revision is). */ +async function product(id: string, creatorId: string, rating: "general" | "adult", retracted = false) { + await db.insert(schema.product).values({ id, creatorId, name: id, rating, latest: 1 }); + await db.insert(schema.productRevision).values({ productId: id, number: 1, files: [], rating, token: "t", retracted }); +} + +/** A proxy's live connection, signed in to the client as imvuId. */ +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 }); + await Bun.sleep(30); + return { ws, got, send, decor: () => got.filter((m) => m.type === "decor") }; +} + +async function waitFor(ok: () => boolean) { + for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(10); + expect(ok()).toBe(true); +} + +const hash = (v: unknown) => createHash("sha256").update(JSON.stringify(v)).digest("hex"); + +test("only the room's owner decorates it; contents are checked", async () => { + const owner = await person("dora", { id: 501 }, { id: 502 }); + const other = await person("dave", { id: 511 }); + const unlinked = await person("dina"); + await product("aaaaaaaaaa", owner.id, "general"); + const room = "502-3"; + const item = [7, "aaaaaaaaaa", "0 0 0"]; + + expect((await call(undefined, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(401); + expect((await call(other, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(403); + expect((await call(unlinked, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(403); + + // Validation. + const put = (contents: unknown, instance = room) => call(owner, "PUT", `/api/v1/decorations/${instance}`, { contents }); + for (const bad of ["502", "0502-3", "502-0", "502-3-4", "x-1", "1234567890123-1"]) + expect((await put([item], bad)).status).toBe(400); + expect((await put([item], "2100000000-5")).json.error).toBe("hosted_room"); + expect((await put([[7, "AAAAAAAAAA", ""]])).status).toBe(400); + expect((await put([[7, 12345, ""]])).status).toBe(400); + expect((await put([[-1, "aaaaaaaaaa", ""]])).status).toBe(400); + expect((await put([[7, "aaaaaaaaaa", "x".repeat(201)]])).status).toBe(400); + expect((await put([[7, "aaaaaaaaaa"]])).status).toBe(400); + expect((await put([item, item])).status).toBe(400); + expect((await put(Array.from({ length: 301 }, (_, i) => [i, "aaaaaaaaaa", ""]))).status).toBe(400); + const unknown = await put([item, [8, "bbbbbbbbbb", ""]]); + expect(unknown.status).toBe(400); + expect(unknown.json.error).toBe("unknown_product"); + + // The owner's other account is fine too; up to 300 items. + const ok = await put(Array.from({ length: 300 }, (_, i) => [i, "aaaaaaaaaa", "0 0 0"])); + expect(ok.status).toBe(200); + expect(ok.json.contents).toHaveLength(300); + const set = await put([item]); + expect(set.json).toEqual({ instance: room, contents: [item], hash: hash([item]) }); + expect((await call(undefined, "GET", `/api/v1/decorations/${room}`)).json).toEqual(set.json); + // Not another person's room of the same number. + expect((await call(owner, "PUT", "/api/v1/decorations/511-3", { contents: [item] })).status).toBe(403); +}); + +test("viewers see the products they may, with a hash of what they see", async () => { + const owner = await person("eli", { id: 601, ageVerified: true }); + const kid = await person("emma", { id: 611 }); + const grown = await person("evan", { id: 621, ageVerified: true }); + await product("cccccccccc", owner.id, "general"); + await product("dddddddddd", owner.id, "adult"); + await product("eeeeeeeeee", owner.id, "general", true); // its only revision is retracted + const room = "601-1"; + const g = [1, "cccccccccc", "a"]; + const a = [2, "dddddddddd", "b"]; + const r = [3, "eeeeeeeeee", "c"]; + + // Unknown rooms have nothing. + expect((await call(undefined, "GET", "/api/v1/decorations/601-9")).json).toEqual({ instance: "601-9", contents: [], hash: hash([]) }); + expect((await call(undefined, "GET", "/api/v1/decorations/601-x")).status).toBe(400); + expect((await call(undefined, "GET", "/api/v1/decorations/2100000000-1")).status).toBe(400); + + const put = await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [g, a, r] }); + expect(put.status).toBe(200); + expect(put.json.contents).toEqual([g, a]); // the owner is adult; the retracted one is left out + + const view = async (p?: Person) => (await call(p, "GET", `/api/v1/decorations/${room}`)).json; + expect(await view()).toEqual({ instance: room, contents: [g], hash: hash([g]) }); + expect(await view(kid)).toEqual({ instance: room, contents: [g], hash: hash([g]) }); + expect(await view(grown)).toEqual({ instance: room, contents: [g, a], hash: hash([g, a]) }); + + // A product kept but hidden comes back once it has a usable revision again. + await db.update(schema.productRevision).set({ retracted: false }).where(eq(schema.productRevision.productId, "eeeeeeeeee")); + expect((await view(kid)).contents).toEqual([g, r]); + + // Changes change the hash. + const before = (await view(kid)).hash; + const moved = [1, "cccccccccc", "moved"]; + await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [moved, a, r] }); + const after = await view(kid); + expect(after.contents).toEqual([moved, r]); + expect(after.hash).not.toBe(before); + + // Several rooms at once, in the order asked. + const many = await call(grown, "GET", `/api/v1/decorations?instances=601-9,${room}`); + expect(many.json.rooms).toEqual([ + { instance: "601-9", contents: [], hash: hash([]) }, + { instance: room, contents: [moved, a, r], hash: hash([moved, a, r]) }, + ]); + expect((await call(undefined, "GET", "/api/v1/decorations")).json).toEqual({ rooms: [] }); + const tooMany = Array.from({ length: 21 }, (_, i) => `601-${i + 1}`).join(","); + expect((await call(undefined, "GET", `/api/v1/decorations?instances=${tooMany}`)).status).toBe(400); + expect((await call(undefined, "GET", "/api/v1/decorations?instances=601-1,nope")).status).toBe(400); + + // An empty list removes them. + const cleared = await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [] }); + expect(cleared.json).toEqual({ instance: room, contents: [], hash: hash([]) }); + expect(await db.select().from(schema.roomDecoration).where(eq(schema.roomDecoration.instanceId, room))).toHaveLength(0); +}); + +test("proxies in the room are told when its decorations change", async () => { + const owner = await person("fay", { id: 701 }); + const guest = await person("finn", { id: 711 }); + const away = await person("fred", { id: 721 }); + await product("ffffffffff", owner.id, "general"); + const room = "701-2"; + + const host = await proxy(owner, 701); // the owner's client, in the room + const editor = await proxy(owner, 701); // the owner's other session, saving (not in the room) + const inRoom = await proxy(guest, 711); + const elsewhere = await proxy(away, 721); + host.send({ type: "join", chat: "1400000001", room }); + inRoom.send({ type: "join", chat: "1400000001", room }); + elsewhere.send({ type: "join", chat: "1400000002", room: "701-3" }); + elsewhere.send({ type: "join", chat: "1400000003" }); + await Bun.sleep(50); + + expect((await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [[1, "ffffffffff", ""]] })).status).toBe(200); + await waitFor(() => host.decor().length === 1 && inRoom.decor().length === 1); + expect(inRoom.decor()[0]).toEqual({ type: "decor", room }); + await Bun.sleep(50); + expect(editor.decor()).toHaveLength(0); + expect(elsewhere.decor()).toHaveLength(0); + + // Not after leaving. + inRoom.send({ type: "leave", chat: "1400000001" }); + await Bun.sleep(30); + await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [] }); + await waitFor(() => host.decor().length === 2); + await Bun.sleep(50); + expect(inRoom.decor()).toHaveLength(1); + + // A refused change tells no one. + await call(guest, "PUT", `/api/v1/decorations/${room}`, { contents: [] }); + await Bun.sleep(50); + expect(host.decor()).toHaveLength(2); + + for (const p of [host, editor, inRoom, elsewhere]) p.ws.close(); +}); + diff --git a/docs/api.md b/docs/api.md index 6ed5cf7..9cfa5ff 100644 --- a/docs/api.md +++ b/docs/api.md @@ -11,7 +11,7 @@ cookies. T5DE's proxy calls it directly with `Authorization: Bearer ", "message": ""}` with a 4xx/5xx status; `message` can be shown as is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_account`, `already_linked`, -`code_missing`, `expired`, `link_cap`, `rate_limited`, `imvu_unavailable`. +`code_missing`, `expired`, `link_cap`, `rate_limited`, `imvu_unavailable`, `unknown_product`, `hosted_room`. ## Accounts (Better Auth) @@ -30,7 +30,7 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco | `POST /api/auth/device/token` | `{grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code, client_id}` → `{access_token}` (a session token, used as a bearer token) or `{error: authorization_pending \| slow_down \| expired_token \| access_denied}` | | `POST /api/v1/device/approve` | (website, signed in) `{code}`: signs the waiting proxy in as this person | | `POST /api/v1/badges` | (bearer or website session) `{imvuIds}` (at most 200 IMVU account IDs) → `{badges}`: those linked to a T5DE account that turned on `showBadge` for them and isn't banned. Nothing else about them. 120 a minute per person. | -| `GET /api/v1/realtime` | WebSocket, bearer token: presence, outfits, the room channel and hosted rooms (`room.*`, docs/hosted-rooms.md). Protocol in `apps/api/src/realtime.ts`. | +| `GET /api/v1/realtime` | WebSocket, bearer token: presence, outfits, the room channel, decoration notices (`decor`) and hosted rooms (`room.*`, docs/hosted-rooms.md). Protocol in `apps/api/src/realtime.ts`. | | `GET /.well-known/jwks.json` | the Ed25519 keys JWTs are signed with | ## Website routes (signed in unless noted) @@ -67,6 +67,20 @@ the `GET`s also signed out. A room is `{n, instanceId, chatId, name, description, roomPid, rating, maxUsers, ownerImvuId, ownerName, occupancy, createdAt}`; `instanceId` is `2100000000-`, `chatId` 2100000000 + n, `occupancy` the people in it now. +## Decorations + +T5 products placed in ordinary IMVU rooms (docs/products.md, "Decorations"). `instance` is IMVU's room instance, +`-` (`^[1-9][0-9]{0,11}-[1-9][0-9]{0,9}$`); hosted rooms' (`2100000000-`) are refused +(400 `hosted_room`: they have their own scene). A room's decorations are `{instance, contents, hash}`: `contents` +is a list of `[furniture instance ID (integer), hub product ID, placement (up to 200 characters)]`, `hash` the +sha256 (hex) of `contents` as JSON. + +| | | +| --- | --- | +| `GET /api/v1/decorations/:instance` | (anyone, also signed out) the room's decorations as the viewer sees them: only products with a revision that isn't retracted, adult ones only for people with an adult account. A room without any: `contents: []`. | +| `GET /api/v1/decorations?instances=a,b` | (anyone) `{rooms: [{instance, contents, hash}]}`, up to 20 rooms, in the order asked | +| `PUT /api/v1/decorations/:instance` | (bearer or website session) `{contents}` → the room's decorations as the person sees them. Only if one of the person's linked IMVU accounts is the room's owner (else 403 `forbidden`). Up to 300 items, each furniture ID once; every product must be registered (400 `unknown_product`). An empty list removes them. Everyone with a chat in the room gets `{"type": "decor", "room": instance}` on the realtime connection. 60 a minute. | + ## Products, catalogs and libraries The routes and tokens are in docs/products.md. Products are `{id, name, description, rating, latest, creator, diff --git a/docs/design.md b/docs/design.md index 2fbcdae..fa5e49e 100644 --- a/docs/design.md +++ b/docs/design.md @@ -287,8 +287,8 @@ is per person, shared across their accounts. ## Rooms (**Decided**: both) -- **Decorations over IMVU rooms**: community furniture placed in an IMVU room, seen only by T5DE users. Placements - travel on the room channel like outfits. +- **Decorations over IMVU rooms**: community furniture placed in an IMVU room, seen only by T5DE users. The hub keeps + them per room; proxies strip them from what IMVU gets and add them back on load (products.md, "Decorations"). - **T5DE-hosted rooms**: the room lives on the hub: scene, furniture placement, owner, moderators, permissions, bans, chat, seats, presence. The proxy routes that room's IMQ traffic to the hub, which speaks the subset of IMQ the client needs (subscribe, state changes, mounts, messages) and the room-joining XML-RPC calls, so the diff --git a/docs/products.md b/docs/products.md index eaf0506..399a5fe 100644 --- a/docs/products.md +++ b/docs/products.md @@ -99,3 +99,20 @@ every 10 minutes, 20 at a time. From there it's served like any pack. - Products others wear in a room that it doesn't have are fetched the same way, then given to the client. - Library products appear in the client's inventory (see the proxy's README). + +## Decorations + +T5 furniture and pose products placed in an ordinary IMVU room (design.md, "Decorations over IMVU rooms"). IMVU +keeps the room without them; the hub keeps them (`room_decoration`, routes in api.md), and only T5DE users see them. + +- **Saving**: when the room's owner saves the room (`test.setRoomState`) or the client broadcasts a furniture change + in it, the proxy takes the hub products out before it goes to IMVU, and `PUT /api/v1/decorations/` the + room's full list of them (`[furniture instance ID, hub product ID, placement]`). +- **Loading**: when the client loads an IMVU room (`test.getRoomState`), the proxy `GET`s its decorations and adds + them to the room's contents (as local IDs, fetching the products as for outfits); the `hash` tells it whether + what it has is current. In the room, an incoming furniture broadcast from IMVU gets the decorations added back + the same way. +- **Changes**: after a `PUT`, every proxy with a chat in that room gets `{"type": "decor", "room": instance}` on the + realtime connection and `GET`s again. +- Viewers only get products they may see: registered, with a revision that isn't retracted, adult ones only for + adult accounts. Furniture instance IDs are the proxy's to choose; they must not clash with IMVU's in that room. diff --git a/packages/db/drizzle/0006_decorations.sql b/packages/db/drizzle/0006_decorations.sql new file mode 100644 index 0000000..b9526ad --- /dev/null +++ b/packages/db/drizzle/0006_decorations.sql @@ -0,0 +1,8 @@ +CREATE TABLE "room_decoration" ( + "instance_id" text PRIMARY KEY NOT NULL, + "owner_imvu_id" bigint NOT NULL, + "contents" jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "room_decoration_owner" ON "room_decoration" USING btree ("owner_imvu_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0006_snapshot.json b/packages/db/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..7954039 --- /dev/null +++ b/packages/db/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1849 @@ +{ + "id": "2854e817-9ec7-4e0b-9d38-f68a9b439aac", + "prevId": "03088cae-4e1c-4f0c-bc16-628a7a7a38d4", + "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.catalog": { + "name": "catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "membership": { + "name": "membership", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_url": { + "name": "catalog_url", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_owner_id_user_id_fk": { + "name": "catalog_owner_id_user_id_fk", + "tableFrom": "catalog", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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.hosted_room": { + "name": "hosted_room", + "schema": "", + "columns": { + "n": { + "name": "n", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "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": "''" + }, + "room_pid": { + "name": "room_pid", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "max_users": { + "name": "max_users", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "scene": { + "name": "scene", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_hash": { + "name": "scene_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "closed": { + "name": "closed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "hosted_room_owner": { + "name": "hosted_room_owner", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hosted_room_created": { + "name": "hosted_room_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosted_room_owner_id_user_id_fk": { + "name": "hosted_room_owner_id_user_id_fk", + "tableFrom": "hosted_room", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosting": { + "name": "hosting", + "schema": "", + "columns": { + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosting_product": { + "name": "hosting_product", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosting_catalog_id_catalog_id_fk": { + "name": "hosting_catalog_id_catalog_id_fk", + "tableFrom": "hosting", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosting_catalog_id_product_id_number_pk": { + "name": "hosting_catalog_id_product_id_number_pk", + "columns": [ + "catalog_id", + "product_id", + "number" + ] + } + }, + "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.library": { + "name": "library", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stay_on": { + "name": "stay_on", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "added_at": { + "name": "added_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "library_user_id_user_id_fk": { + "name": "library_user_id_user_id_fk", + "tableFrom": "library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "library_product_id_product_id_fk": { + "name": "library_product_id_product_id_fk", + "tableFrom": "library", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "library_user_id_product_id_pk": { + "name": "library_user_id_product_id_pk", + "columns": [ + "user_id", + "product_id" + ] + } + }, + "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.product": { + "name": "product", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest": { + "name": "latest", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "product_creator": { + "name": "product_creator", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_created": { + "name": "product_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_creator_id_user_id_fk": { + "name": "product_creator_id_user_id_fk", + "tableFrom": "product", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_revision": { + "name": "product_revision", + "schema": "", + "columns": { + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent": { + "name": "parent", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parent_info": { + "name": "parent_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retracted": { + "name": "retracted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "product_revision_product_id_product_id_fk": { + "name": "product_revision_product_id_product_id_fk", + "tableFrom": "product_revision", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "product_revision_uploaded_by_user_id_fk": { + "name": "product_revision_uploaded_by_user_id_fk", + "tableFrom": "product_revision", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "product_revision_product_id_number_pk": { + "name": "product_revision_product_id_number_pk", + "columns": [ + "product_id", + "number" + ] + } + }, + "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.room_decoration": { + "name": "room_decoration", + "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 + }, + "contents": { + "name": "contents", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "room_decoration_owner": { + "name": "room_decoration_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.signing_key": { + "name": "signing_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 16f254f..ae508f5 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1793355138984, "tag": "0005_hosted_rooms", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1793357692630, + "tag": "0006_decorations", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 00c4356..b95206d 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url"; import * as schema from "./schema"; export { schema }; -export type { FileEntry, ParentInfo, SceneItem } from "./schema"; +export type { DecorationItem, FileEntry, ParentInfo, SceneItem } from "./schema"; export * from "drizzle-orm"; const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url)); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 85cb68e..4d1c685 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -361,3 +361,22 @@ export const hostedRoom = pgTable( }, (t) => [index("hosted_room_owner").on(t.ownerId), index("hosted_room_created").on(t.createdAt)], ); + +// ---- Decorations over IMVU rooms (docs/products.md, "Decorations") ---- + +/** A decoration: [furniture instance ID, hub product ID, placement], as the client's room_contents. */ +export type DecorationItem = [number, string, string]; + +// T5 products placed in an IMVU room by its owner. IMVU keeps the room without them (T5DE's proxy strips them from +// what the client saves); the proxies of T5DE users in the room add them back. Hosted rooms keep theirs in +// hosted_room.scene instead. +export const roomDecoration = pgTable( + "room_decoration", + { + instanceId: text("instance_id").primaryKey(), // IMVU's room instance, "-" + ownerImvuId: bigint("owner_imvu_id", { mode: "number" }).notNull(), // the number before the dash + contents: jsonb("contents").$type().notNull(), + updatedAt: updatedAt(), + }, + (t) => [index("room_decoration_owner").on(t.ownerImvuId)], +);