CI / test (push) Successful in 1m4s
A creator deletes a product: it's no longer listed and nobody new can get it, while people who have it keep it (its page, files and library entries stay, and rooms keep showing it), as old revisions stay available. Moderation removes one everywhere, with a reason: its page and files answer 410, and it leaves libraries and rooms. Both are on the product's page and in the audit log. Co-Authored-By: Claude Opus 5.5 <[email protected]>
476 lines
19 KiB
TypeScript
476 lines
19 KiB
TypeScript
// The hub's tables: Better Auth's (people, sessions, passwords, signing keys, device sign-ins) and the hub's own
|
|
// (linked IMVU accounts, link codes, the audit log). Better Auth's are as its CLI generates them for the plugins
|
|
// used (username, admin, device-authorization, bearer, jwt), with the hub's linkCap added to user.
|
|
import { sql } from "drizzle-orm";
|
|
import {
|
|
bigint,
|
|
boolean,
|
|
customType,
|
|
index,
|
|
integer,
|
|
jsonb,
|
|
pgTable,
|
|
primaryKey,
|
|
serial,
|
|
text,
|
|
timestamp,
|
|
uniqueIndex,
|
|
} from "drizzle-orm/pg-core";
|
|
|
|
const createdAt = () => timestamp("created_at", { withTimezone: true }).notNull().defaultNow();
|
|
const updatedAt = () =>
|
|
timestamp("updated_at", { withTimezone: true })
|
|
.notNull()
|
|
.defaultNow()
|
|
.$onUpdate(() => new Date());
|
|
|
|
// A person: one T5DE account.
|
|
export const user = pgTable(
|
|
"user",
|
|
{
|
|
id: text("id").primaryKey(),
|
|
name: text("name").notNull(),
|
|
email: text("email").notNull().unique(),
|
|
emailVerified: boolean("email_verified").notNull().default(false),
|
|
image: text("image"),
|
|
createdAt: createdAt(),
|
|
updatedAt: updatedAt(),
|
|
username: text("username").unique(),
|
|
displayUsername: text("display_username"),
|
|
role: text("role").notNull().default("user"),
|
|
banned: boolean("banned").default(false),
|
|
banReason: text("ban_reason"),
|
|
banExpires: timestamp("ban_expires", { withTimezone: true }),
|
|
// How many IMVU accounts the person may link; moderators raise it on request.
|
|
linkCap: integer("link_cap").notNull().default(10),
|
|
},
|
|
(t) => [uniqueIndex("user_username_lower").on(sql`lower(${t.username})`)],
|
|
);
|
|
|
|
export const session = pgTable(
|
|
"session",
|
|
{
|
|
id: text("id").primaryKey(),
|
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
token: text("token").notNull().unique(),
|
|
createdAt: createdAt(),
|
|
updatedAt: updatedAt(),
|
|
ipAddress: text("ip_address"),
|
|
userAgent: text("user_agent"),
|
|
userId: text("user_id")
|
|
.notNull()
|
|
.references(() => user.id, { onDelete: "cascade" }),
|
|
impersonatedBy: text("impersonated_by"),
|
|
},
|
|
(t) => [index("session_user").on(t.userId)],
|
|
);
|
|
|
|
export const account = pgTable(
|
|
"account",
|
|
{
|
|
id: text("id").primaryKey(),
|
|
accountId: text("account_id").notNull(),
|
|
providerId: text("provider_id").notNull(),
|
|
userId: text("user_id")
|
|
.notNull()
|
|
.references(() => user.id, { onDelete: "cascade" }),
|
|
accessToken: text("access_token"),
|
|
refreshToken: text("refresh_token"),
|
|
idToken: text("id_token"),
|
|
accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true }),
|
|
refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true }),
|
|
scope: text("scope"),
|
|
password: text("password"),
|
|
createdAt: createdAt(),
|
|
updatedAt: updatedAt(),
|
|
},
|
|
(t) => [index("account_user").on(t.userId)],
|
|
);
|
|
|
|
export const verification = pgTable(
|
|
"verification",
|
|
{
|
|
id: text("id").primaryKey(),
|
|
identifier: text("identifier").notNull(),
|
|
value: text("value").notNull(),
|
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
createdAt: createdAt(),
|
|
updatedAt: updatedAt(),
|
|
},
|
|
(t) => [index("verification_identifier").on(t.identifier)],
|
|
);
|
|
|
|
export const jwks = pgTable("jwks", {
|
|
id: text("id").primaryKey(),
|
|
publicKey: text("public_key").notNull(),
|
|
privateKey: text("private_key").notNull(),
|
|
createdAt: createdAt(),
|
|
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
|
alg: text("alg"),
|
|
crv: text("crv"),
|
|
});
|
|
|
|
// A T5DE proxy signing in: it shows userCode, the person approves it on the website.
|
|
export const deviceCode = pgTable("device_code", {
|
|
id: text("id").primaryKey(),
|
|
deviceCode: text("device_code").notNull(),
|
|
userCode: text("user_code").notNull(),
|
|
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
|
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
status: text("status").notNull(),
|
|
lastPolledAt: timestamp("last_polled_at", { withTimezone: true }),
|
|
pollingInterval: integer("polling_interval"),
|
|
clientId: text("client_id"),
|
|
scope: text("scope"),
|
|
createdAt: createdAt(),
|
|
updatedAt: updatedAt(),
|
|
});
|
|
|
|
// A linked IMVU account. Linking it again from another person moves it (the most recent proof wins).
|
|
export const imvuLink = pgTable(
|
|
"imvu_link",
|
|
{
|
|
imvuId: bigint("imvu_id", { mode: "number" }).primaryKey(),
|
|
userId: text("user_id")
|
|
.notNull()
|
|
.references(() => user.id, { onDelete: "cascade" }),
|
|
// What catalogs and rooms see instead of the person, so a person's accounts can't be connected.
|
|
pairwiseId: text("pairwise_id").notNull().unique(),
|
|
username: text("username").notNull(),
|
|
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(), // "<owner>-<n>"
|
|
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(
|
|
"age_check",
|
|
{
|
|
id: text("id").primaryKey(),
|
|
userId: text("user_id")
|
|
.notNull()
|
|
.references(() => user.id, { onDelete: "cascade" }),
|
|
// Set when the check is of one IMVU account (IMVU's own verification); null when it's of the person.
|
|
imvuId: bigint("imvu_id", { mode: "number" }),
|
|
provider: text("provider").notNull(), // "imvu", "didit", "wallet", ...
|
|
method: text("method").notNull().default(""), // e.g. "imvu_age_verified", "id_document", "wallet_mdoc"
|
|
status: text("status").notNull(), // "pending", "complete", "failed", "revoked"
|
|
overEighteen: boolean("over_eighteen").notNull().default(false),
|
|
providerRef: text("provider_ref"), // the provider's session or transaction ID
|
|
createdAt: createdAt(),
|
|
checkedAt: timestamp("checked_at", { withTimezone: true }),
|
|
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
|
},
|
|
(t) => [
|
|
index("age_check_user").on(t.userId),
|
|
index("age_check_imvu").on(t.imvuId),
|
|
uniqueIndex("age_check_provider_ref").on(t.provider, t.providerRef),
|
|
],
|
|
);
|
|
|
|
// A code the person is to put in an IMVU account's tagline, proving it's theirs.
|
|
export const linkChallenge = pgTable(
|
|
"link_challenge",
|
|
{
|
|
userId: text("user_id")
|
|
.notNull()
|
|
.references(() => user.id, { onDelete: "cascade" }),
|
|
imvuId: bigint("imvu_id", { mode: "number" }).notNull(),
|
|
username: text("username").notNull(),
|
|
code: text("code").notNull(),
|
|
createdAt: createdAt(),
|
|
},
|
|
(t) => [primaryKey({ columns: [t.userId, t.imvuId] })],
|
|
);
|
|
|
|
export const audit = pgTable(
|
|
"audit",
|
|
{
|
|
id: serial("id").primaryKey(),
|
|
at: timestamp("at", { withTimezone: true }).notNull().defaultNow(),
|
|
actorId: text("actor_id").references(() => user.id, { onDelete: "set null" }),
|
|
action: text("action").notNull(),
|
|
target: text("target").notNull().default(""),
|
|
detail: jsonb("detail").$type<Record<string, unknown>>().notNull().default({}),
|
|
},
|
|
(t) => [index("audit_at").on(t.at)],
|
|
);
|
|
|
|
// ---- Products (docs/products.md) ----
|
|
|
|
export type FileEntry = { name: string; sha256: string; size: number };
|
|
|
|
/** The public info of the IMVU product a revision derives from (IMVU's API), when it was looked up. */
|
|
export type ParentInfo = {
|
|
found: boolean; // false: IMVU shows no such product publicly
|
|
image: string | null; // https, on *.imvu.com
|
|
categories: { id: number; name: string }[]; // outermost first
|
|
gender: string; // "female", "male" or ""
|
|
checkedAt: string; // ISO time
|
|
};
|
|
|
|
// The hub's own signing keys (Ed25519) for the tokens catalogs and proxies check. The newest is used.
|
|
export const signingKey = pgTable("signing_key", {
|
|
id: text("id").primaryKey(), // the JWK's kid
|
|
publicKey: jsonb("public_key").$type<Record<string, string>>().notNull(),
|
|
privateKey: jsonb("private_key").$type<Record<string, string>>().notNull(),
|
|
createdAt: createdAt(),
|
|
});
|
|
|
|
// A catalog: a server hosting product files. Hidden until a moderator approves it.
|
|
export const catalog = pgTable(
|
|
"catalog",
|
|
{
|
|
id: text("id").primaryKey(),
|
|
ownerId: text("owner_id")
|
|
.notNull()
|
|
.references(() => user.id, { onDelete: "cascade" }),
|
|
name: text("name").notNull(),
|
|
url: text("url").notNull(), // its public URL
|
|
contact: text("contact").notNull(), // takedown and legal contact
|
|
visibility: text("visibility").notNull().default("public"), // "public", "private"
|
|
membership: text("membership").notNull().default("open"), // "open", "approval", "invite"
|
|
status: text("status").notNull().default("pending"), // "pending", "approved", "rejected"
|
|
keyHash: text("key_hash").notNull(), // sha256 of its key, hex
|
|
createdAt: createdAt(),
|
|
},
|
|
(t) => [uniqueIndex("catalog_url").on(t.url)],
|
|
);
|
|
|
|
// A product: its info, owned by its creator. Its files are in revisions.
|
|
export const product = pgTable(
|
|
"product",
|
|
{
|
|
id: text("id").primaryKey(), // 10 of a-z2-7
|
|
creatorId: text("creator_id")
|
|
.notNull()
|
|
.references(() => user.id, { onDelete: "restrict" }),
|
|
name: text("name").notNull(),
|
|
description: text("description").notNull().default(""), // HTML, already cleaned
|
|
rating: text("rating").notNull(), // "general", "adult": the highest of its revisions, or raised by the creator
|
|
latest: integer("latest").notNull().default(0), // the newest revision's number
|
|
// Deleted by its creator: no longer listed or added to libraries; those who have it keep it, and rooms keep it.
|
|
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
|
// Removed by moderation: gone everywhere (no page, no files, out of libraries and rooms).
|
|
removedAt: timestamp("removed_at", { withTimezone: true }),
|
|
removedReason: text("removed_reason"),
|
|
createdAt: createdAt(),
|
|
updatedAt: updatedAt(),
|
|
},
|
|
(t) => [index("product_creator").on(t.creatorId), index("product_created").on(t.createdAt)],
|
|
);
|
|
|
|
// A revision: a product's files. Never changes (but can be retracted).
|
|
export const productRevision = pgTable(
|
|
"product_revision",
|
|
{
|
|
productId: text("product_id")
|
|
.notNull()
|
|
.references(() => product.id, { onDelete: "cascade" }),
|
|
number: integer("number").notNull(),
|
|
files: jsonb("files").$type<FileEntry[]>().notNull(),
|
|
rating: text("rating").notNull(),
|
|
parent: bigint("parent", { mode: "number" }).notNull().default(0), // the IMVU product it derives from
|
|
parentInfo: jsonb("parent_info").$type<ParentInfo>(), // null until looked up (parent > 0)
|
|
notes: text("notes").notNull().default(""),
|
|
token: text("token").notNull(), // the hub's t5de-revision token
|
|
uploadedBy: text("uploaded_by").references(() => user.id, { onDelete: "set null" }),
|
|
retracted: boolean("retracted").notNull().default(false),
|
|
createdAt: createdAt(),
|
|
},
|
|
(t) => [primaryKey({ columns: [t.productId, t.number] })],
|
|
);
|
|
|
|
// Which catalogs host which revisions.
|
|
export const hosting = pgTable(
|
|
"hosting",
|
|
{
|
|
catalogId: text("catalog_id")
|
|
.notNull()
|
|
.references(() => catalog.id, { onDelete: "cascade" }),
|
|
productId: text("product_id").notNull(),
|
|
number: integer("number").notNull(),
|
|
createdAt: createdAt(),
|
|
},
|
|
(t) => [primaryKey({ columns: [t.catalogId, t.productId, t.number] }), index("hosting_product").on(t.productId)],
|
|
);
|
|
|
|
// A person's library: products they got, optionally staying on a revision.
|
|
export const library = pgTable(
|
|
"library",
|
|
{
|
|
userId: text("user_id")
|
|
.notNull()
|
|
.references(() => user.id, { onDelete: "cascade" }),
|
|
productId: text("product_id")
|
|
.notNull()
|
|
.references(() => product.id, { onDelete: "cascade" }),
|
|
stayOn: integer("stay_on"),
|
|
addedAt: timestamp("added_at", { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(t) => [primaryKey({ columns: [t.userId, t.productId] })],
|
|
);
|
|
|
|
// ---- T5DE-hosted rooms (docs/hosted-rooms.md) ----
|
|
|
|
/** A scene item, as the client's room_contents: [furniture instance ID, product ID (IMVU's or the hub's), placement]. */
|
|
export type SceneItem = [number, number | string, string];
|
|
|
|
// A room hosted by the hub: its instance ID is "2100000000-<n>", its chat ID 2100000000 + n. Who's in it is kept in
|
|
// memory only (apps/api/src/realtime.ts). Closing it keeps the row, so its number is never reused.
|
|
export const hostedRoom = pgTable(
|
|
"hosted_room",
|
|
{
|
|
n: serial("n").primaryKey(),
|
|
ownerId: text("owner_id")
|
|
.notNull()
|
|
.references(() => user.id, { onDelete: "cascade" }),
|
|
// One of the owner's linked IMVU accounts, shown as the room's owner (the client lets it set the scene).
|
|
ownerImvuId: bigint("owner_imvu_id", { mode: "number" }).notNull(),
|
|
name: text("name").notNull(),
|
|
description: text("description").notNull().default(""), // plain text
|
|
roomPid: bigint("room_pid", { mode: "number" }).notNull(), // the room shell, an IMVU product
|
|
rating: text("rating").notNull().default("general"), // "general", "adult"
|
|
maxUsers: integer("max_users").notNull().default(10),
|
|
scene: jsonb("scene").$type<SceneItem[]>().notNull().default([]),
|
|
sceneHash: text("scene_hash").notNull().default(""),
|
|
closed: boolean("closed").notNull().default(false),
|
|
// Phase 2 (docs/hosted-rooms.md): "public" or "private" (unlisted; joined by the owner, moderators and invitees).
|
|
privacy: text("privacy").notNull().default("public"),
|
|
// Up to 20 IMVU accounts that may boot and ban (and invite to a private room), with their names as known then.
|
|
moderators: jsonb("moderators").$type<RoomModerator[]>().notNull().default([]),
|
|
// The room's image (hosted_room_image), by the sha256 of its bytes; null: none.
|
|
imageHash: text("image_hash"),
|
|
createdAt: createdAt(),
|
|
updatedAt: updatedAt(),
|
|
},
|
|
(t) => [index("hosted_room_owner").on(t.ownerId), index("hosted_room_created").on(t.createdAt)],
|
|
);
|
|
|
|
/** A hosted room's moderator: an IMVU account, and its name as known when it was added ("" if unknown). */
|
|
export type RoomModerator = { imvuId: number; username: string };
|
|
|
|
const bytea = customType<{ data: Buffer; driverData: Buffer | Uint8Array }>({
|
|
dataType: () => "bytea",
|
|
fromDriver: (v) => Buffer.from(v),
|
|
});
|
|
|
|
// A hosted room's image, as uploaded (PNG or JPEG, checked by its magic bytes, up to 512 KB).
|
|
export const hostedRoomImage = pgTable("hosted_room_image", {
|
|
n: integer("n")
|
|
.primaryKey()
|
|
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
|
contentType: text("content_type").notNull(), // "image/png", "image/jpeg"
|
|
data: bytea("data").notNull(),
|
|
hash: text("hash").notNull(), // sha256, hex
|
|
updatedAt: updatedAt(),
|
|
});
|
|
|
|
// IMVU accounts banned from a hosted room; until null: for good.
|
|
export const hostedRoomBan = pgTable(
|
|
"hosted_room_ban",
|
|
{
|
|
n: integer("n")
|
|
.notNull()
|
|
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
|
imvuId: bigint("imvu_id", { mode: "number" }).notNull(),
|
|
byImvuId: bigint("by_imvu_id", { mode: "number" }).notNull(),
|
|
until: timestamp("until", { withTimezone: true }),
|
|
createdAt: createdAt(),
|
|
},
|
|
(t) => [primaryKey({ columns: [t.n, t.imvuId] })],
|
|
);
|
|
|
|
// A person's favorite hosted rooms.
|
|
export const hostedRoomFavorite = pgTable(
|
|
"hosted_room_favorite",
|
|
{
|
|
userId: text("user_id")
|
|
.notNull()
|
|
.references(() => user.id, { onDelete: "cascade" }),
|
|
n: integer("n")
|
|
.notNull()
|
|
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
|
createdAt: createdAt(),
|
|
},
|
|
(t) => [primaryKey({ columns: [t.userId, t.n] })],
|
|
);
|
|
|
|
// A person's rating of a hosted room, 1-5.
|
|
export const hostedRoomRating = pgTable(
|
|
"hosted_room_rating",
|
|
{
|
|
userId: text("user_id")
|
|
.notNull()
|
|
.references(() => user.id, { onDelete: "cascade" }),
|
|
n: integer("n")
|
|
.notNull()
|
|
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
|
score: integer("score").notNull(),
|
|
updatedAt: updatedAt(),
|
|
},
|
|
(t) => [primaryKey({ columns: [t.userId, t.n] }), index("hosted_room_rating_room").on(t.n)],
|
|
);
|
|
|
|
// A person's latest visit to each hosted room (room.join), with the IMVU account they joined as. 50 are kept.
|
|
export const hostedRoomVisit = pgTable(
|
|
"hosted_room_visit",
|
|
{
|
|
userId: text("user_id")
|
|
.notNull()
|
|
.references(() => user.id, { onDelete: "cascade" }),
|
|
n: integer("n")
|
|
.notNull()
|
|
.references(() => hostedRoom.n, { onDelete: "cascade" }),
|
|
imvuId: bigint("imvu_id", { mode: "number" }).notNull(),
|
|
at: timestamp("at", { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(t) => [primaryKey({ columns: [t.userId, t.n] }), index("hosted_room_visit_at").on(t.userId, t.at)],
|
|
);
|
|
|
|
// ---- 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, "<owner>-<n>"
|
|
ownerImvuId: bigint("owner_imvu_id", { mode: "number" }).notNull(), // the number before the dash
|
|
contents: jsonb("contents").$type<DecorationItem[]>().notNull(),
|
|
updatedAt: updatedAt(),
|
|
},
|
|
(t) => [index("room_decoration_owner").on(t.ownerImvuId)],
|
|
);
|