From 370ae0646a563869b15370c7527306efa3ac20f7 Mon Sep 17 00:00:00 2001 From: dhkatz Date: Wed, 4 Nov 2026 23:19:15 -0800 Subject: [PATCH] feat: permissions, a console for everyone who manages something, and catalog management Phase 1 of docs/console.md: permissions as resource:action strings in role bundles at a scope; the console for anyone controlling a catalog, product or room, with a tagged audit log scoped by permission and filters; catalog settings (rating ceiling, upload policy), key rotation, operators, membership (approval, invitations, bans) enforced when getting and wearing products, delisting, suspension, and the catalogs' changes feed. Co-Authored-By: Claude Opus 5.5 --- apps/api/src/app.ts | 608 +++- apps/api/src/catalogs.ts | 387 +++ apps/api/src/permissions.ts | 259 ++ apps/api/src/products.ts | 64 +- apps/api/src/realtime.ts | 16 +- apps/api/test/catalogs.test.ts | 338 ++ apps/api/test/hub.test.ts | 4 +- apps/api/test/registration.test.ts | 2 +- apps/web/app/catalogs/[id]/page.tsx | 100 + apps/web/app/catalogs/page.tsx | 7 +- apps/web/app/console/audit-table.tsx | 80 + apps/web/app/console/audit/page.tsx | 67 + apps/web/app/console/catalogs/[id]/page.tsx | 498 +++ apps/web/app/console/catalogs/actions.ts | 122 + apps/web/app/console/catalogs/page.tsx | 70 +- apps/web/app/console/console-nav.tsx | 50 +- apps/web/app/console/page.tsx | 172 +- apps/web/app/products/[id]/page.tsx | 18 +- apps/web/app/products/page.tsx | 20 +- apps/web/components/catalogs.tsx | 4 +- apps/web/components/header.tsx | 4 +- apps/web/lib/api.ts | 5 + docs/api.md | 17 +- docs/design.md | 2 + .../db/drizzle/0010_catalog_management.sql | 62 + packages/db/drizzle/meta/0010_snapshot.json | 2768 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/index.ts | 2 +- packages/db/src/schema.ts | 85 +- 29 files changed, 5657 insertions(+), 181 deletions(-) create mode 100644 apps/api/src/catalogs.ts create mode 100644 apps/api/src/permissions.ts create mode 100644 apps/api/test/catalogs.test.ts create mode 100644 apps/web/app/catalogs/[id]/page.tsx create mode 100644 apps/web/app/console/audit-table.tsx create mode 100644 apps/web/app/console/audit/page.tsx create mode 100644 apps/web/app/console/catalogs/[id]/page.tsx create mode 100644 apps/web/app/console/catalogs/actions.ts create mode 100644 packages/db/drizzle/0010_catalog_management.sql create mode 100644 packages/db/drizzle/meta/0010_snapshot.json diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 7c52b50..31297dc 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -15,6 +15,7 @@ import { count, schema, type Database, + type AuditTags, type ParentInfo, type RoomModerator, } from "@t5de/db"; @@ -25,7 +26,7 @@ import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu"; import { Realtime } from "./realtime"; import { isAdultRoom, Rooms } from "./rooms"; import { Signer } from "./keys"; -import { ProductError, Products } from "./products"; +import { availableTo, ProductError, Products } from "./products"; import { chatId, HostedError, @@ -41,8 +42,10 @@ import { } from "./hosted"; import { DecorationError, Decorations, maxBatch, maxDecorationPlacement, maxDecorations } from "./decorations"; import { Settings } from "./settings"; +import { Catalogs } from "./catalogs"; +import { assignableCatalogRoles, Forbidden, hubScope, Permissions, roles as roleBundles, type Scope } from "./permissions"; -const { user, session, imvuLink, linkChallenge, audit } = schema; +const { user, session, imvuLink, linkChallenge, audit, hostedRoom, catalog, product } = schema; export type Deps = { db: Database; @@ -55,6 +58,8 @@ export type Deps = { apiURL?: string; /** The website's address: providers send people back only to it. */ webURL?: string; + /** Whether a catalog answers at an address (its /health), checked when a catalog's address changes. */ + catalogHealthy?: (url: string) => Promise; /** The hub's settings (default: kept in the database, with no environment defaults). */ settings?: Settings; }; @@ -65,11 +70,19 @@ export class Problem extends Error { readonly status: number, readonly slug: string, message: string, + /** More for the website, e.g. the permission that was missing. */ + readonly extra: Record = {}, ) { super(message); } } +/** A missing permission as the API answers it: 403 forbidden, naming the permission and its scope. */ +const forbiddenProblem = (e: Forbidden) => + new Problem(403, "forbidden", e.message, { + missing: { permission: e.permission, scope: e.scope.type === "hub" ? { hub: true } : { [e.scope.type]: e.scope.id } }, + }); + type Person = typeof user.$inferSelect; type Link = typeof imvuLink.$inferSelect; @@ -117,11 +130,14 @@ export function createApp({ apiURL, webURL, settings: hubSettings, + catalogHealthy, }: Deps) { const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]); const settings = hubSettings ?? new Settings(db); const signer = new Signer(db, apiURL ?? "http://localhost:4101"); const products = new Products(db, signer, imvu); + const perms = new Permissions(db); + const catalogs = new Catalogs(db, catalogHealthy); const rooms = roomsService ?? new Rooms(db, imvu); const hosted = new HostedRooms(db); const decorations = new Decorations(db); @@ -154,6 +170,13 @@ export function createApp({ hostedVisit: (userId, n, imvuId) => { hosted.visit(userId, n, imvuId).catch((e) => console.error("hosted visit:", e)); }, + wearable: async (userId, ids) => { + const rows = await db + .select({ id: product.id }) + .from(product) + .where(and(inArray(product.id, ids), availableTo(userId))); + return new Set(rows.map((r) => r.id)); + }, }); const limiter = new Limiter(); @@ -220,8 +243,33 @@ export function createApp({ }; const linkView = async (l: Link) => (await linkViews(l.userId, [l]))[0]!; - const audited = (actorId: string | null, action: string, target: string, detail: Record = {}) => - db.insert(audit).values({ actorId, action, target, detail }); + /** Writes an audit entry. Tags (who may see it, docs/console.md) default from the action: catalog.*, product.* + * and hosted_room.* entries are about their target. */ + const audited = ( + actorId: string | null, + action: string, + target: string, + detail: Record = {}, + tags?: AuditTags, + ) => { + const t: AuditTags = tags ?? {}; + if (!tags && target) { + if (action.startsWith("catalog.")) t.catalog = target; + else if (action.startsWith("product.")) t.product = target; + else if (action.startsWith("hosted_room.")) t.room = target; + } + return db.insert(audit).values({ actorId, action, target, detail, tags: t }); + }; + + /** Runs a check, turning a missing permission into a Problem. */ + const need = async (who: Person, permission: Parameters[1], scope: Scope) => { + try { + await perms.require(who, permission, scope); + } catch (e) { + if (e instanceof Forbidden) throw forbiddenProblem(e); + throw e; + } + }; /** Who's online that viewer may see: accounts showing their presence (moderators see all). */ const visibleOnline = async (viewer: Person | undefined) => { @@ -298,9 +346,124 @@ export function createApp({ visibility: c.visibility, membership: c.membership, status: c.status, + statusReason: c.statusReason, + maxRating: c.maxRating, + uploads: c.uploads, + mirrors: c.mirrors, createdAt: c.createdAt.toISOString(), }); + const catalogParam = t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }) }); + const catalogScope = (id: string): Scope => ({ type: "catalog", id }); + + const inviteView = (i: typeof schema.catalogInvite.$inferSelect) => ({ + code: i.code, + maxUses: i.maxUses, + uses: i.uses, + expiresAt: i.expiresAt?.toISOString() ?? null, + createdAt: i.createdAt.toISOString(), + url: `${(webURL ?? "").replace(/\/+$/, "")}/catalogs/${i.catalogId}?invite=${i.code}`, + }); + + type ConsoleSection = "overview" | "products" | "catalogs" | "people" | "audit" | "settings"; + + /** What a person controls: the catalogs they're on the team of, their products, their hosted rooms. */ + const consoleScope = async (me: Person) => ({ + catalogs: await perms.teamCatalogs(me.id), + products: await perms.ownProducts(me.id), + rooms: (await db.select({ n: hostedRoom.n }).from(hostedRoom).where(eq(hostedRoom.ownerId, me.id))).map((r) => String(r.n)), + }); + + type AuditQuery = { + catalog?: string; + product?: string; + room?: string; + person?: string; + actor?: string; + action?: string; + q?: string; + from?: string; + to?: string; + before?: number; + limit?: number; + }; + + /** + * Audit entries a person may see (docs/console.md): moderators all; others those they made, and those tagged + * with a catalog they're on the team of, a product of theirs or a hosted room of theirs. Newest first. + */ + const auditFor = async (me: Person, scope: Awaited>, f: AuditQuery) => { + const conds = []; + if (!isModerator(me)) { + const visible = [eq(audit.actorId, me.id)]; + if (scope.catalogs.length) visible.push(inArray(sql`${audit.tags}->>'catalog'`, scope.catalogs)); + if (scope.products.length) visible.push(inArray(sql`${audit.tags}->>'product'`, scope.products)); + if (scope.rooms.length) visible.push(inArray(sql`${audit.tags}->>'room'`, scope.rooms)); + conds.push(or(...visible)!); + } + for (const k of ["catalog", "product", "room"] as const) if (f[k]) conds.push(sql`${audit.tags}->>${k} = ${f[k]}`); + if (f.person) { + const [p] = await db.select({ id: user.id }).from(user).where(sql`lower(${user.username}) = lower(${f.person})`); + conds.push(p ? or(sql`${audit.tags}->>'person' = ${p.id}`, eq(audit.actorId, p.id))! : sql`false`); + } + if (f.actor) { + const [p] = await db.select({ id: user.id }).from(user).where(sql`lower(${user.username}) = lower(${f.actor})`); + conds.push(p ? eq(audit.actorId, p.id) : sql`false`); + } + const esc = (v: string) => v.replace(/[%_\\]/g, (c) => "\\" + c); + if (f.action) conds.push(ilike(audit.action, `${esc(f.action)}%`)); + if (f.q) { + const like = `%${esc(f.q)}%`; + conds.push(or(ilike(audit.action, like), ilike(audit.target, like), sql`${audit.detail}::text ilike ${like}`)!); + } + const day = (v: string | undefined) => (v && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : undefined); + if (day(f.from)) conds.push(sql`${audit.at} >= ${day(f.from)}::date`); + if (day(f.to)) conds.push(sql`${audit.at} < ${day(f.to)}::date + 1`); + if (f.before) conds.push(sql`${audit.id} < ${f.before}`); + const rows = await db + .select({ + id: audit.id, + at: audit.at, + actor: user.username, + action: audit.action, + target: audit.target, + tags: audit.tags, + detail: audit.detail, + }) + .from(audit) + .leftJoin(user, eq(user.id, audit.actorId)) + .where(conds.length ? and(...conds) : undefined) + .orderBy(desc(audit.id)) + .limit(Math.min(f.limit ?? 100, 200)); + // Names for the tags, so the console can show and link them. + const ids = (k: keyof AuditTags) => [...new Set(rows.map((r) => r.tags[k]).filter((v): v is string => !!v))]; + const [cs, ps, people] = await Promise.all([ + ids("catalog").length ? db.select({ id: catalog.id, name: catalog.name }).from(catalog).where(inArray(catalog.id, ids("catalog"))) : [], + ids("product").length ? db.select({ id: product.id, name: product.name }).from(product).where(inArray(product.id, ids("product"))) : [], + ids("person").length + ? db.select({ id: user.id, name: user.username }).from(user).where(inArray(user.id, ids("person"))) + : [], + ]); + const names: Record = {}; + for (const x of [...cs, ...ps, ...people]) names[x.id] = x.name ?? ""; + return rows.map((r) => ({ + id: r.id, + at: r.at.toISOString(), + actor: r.actor ?? "", + action: r.action, + target: r.target, + tags: r.tags, + tagNames: Object.fromEntries(Object.values(r.tags).filter((v): v is string => !!v && names[v] !== undefined).map((v) => [v, names[v]!])), + detail: r.detail, + })); + }; + + const personByUsername = async (username: string) => { + const [p] = await db.select().from(user).where(sql`lower(${user.username}) = lower(${username.trim()})`); + if (!p) throw new Problem(404, "not_found", `Nobody on T5DE is called ${username.trim()}.`); + return p; + }; + /** Runs a hosted-room operation, turning its errors into Problems. */ const hostedCall = async (f: () => Promise): Promise => { try { @@ -452,7 +615,7 @@ export function createApp({ .onError(({ code, error, set }) => { if (code === "Problem") { set.status = error.status; - return { error: error.slug, message: error.message }; + return { error: error.slug, message: error.message, ...error.extra }; } if (code === "VALIDATION") { set.status = 400; @@ -501,7 +664,19 @@ export function createApp({ }) // Who's signed in, and the home page. - .get("/me", async ({ me }) => ({ person: { ...personView(me), picture: await picture(me.id) } }), { account: true }) + .get( + "/me", + async ({ me, person }) => { + // Whether the console is for them: moderators, and anyone controlling a catalog, product or room. + let console = false; + if (person) { + const scope = await consoleScope(person); + console = isModerator(person) || scope.catalogs.length + scope.products.length + scope.rooms.length > 0; + } + return { person: { ...personView(me), picture: await picture(me.id), console } }; + }, + { account: true }, + ) // The hub's public settings: whether new accounts wait for approval. .get("/hub", async () => ({ registration: await settings.registration() })) .get("/home", async ({ person }) => { @@ -1104,6 +1279,298 @@ export function createApp({ catalogs: (await products.catalogs(person?.id)).map((c) => ({ ...catalogView(c), mine: c.ownerId === person?.id })), })) + // Managing a catalog (docs/products.md; permissions in docs/console.md). + .get( + "/catalogs/:id", + async ({ person, params }) => { + const c = await productCall(() => catalogs.get(params.id)); + const held = [...(await perms.at(person, catalogScope(c.id)))]; + const team = held.includes("audit:read"); + if (c.status !== "approved" && !team) throw new Problem(404, "not_found", "No such catalog."); + return { + catalog: { ...catalogView(c), mine: c.ownerId === person?.id }, + membership: person ? ((await catalogs.membership(c.id, person.id)) ?? null) : null, + permissions: held, + roles: await perms.rolesAt(person, catalogScope(c.id)), + hosted: await catalogs.hostedCounts(c.id), + members: team ? await catalogs.memberCounts(c.id) : null, + }; + }, + { params: catalogParam }, + ) + .patch( + "/catalogs/:id", + async ({ me, params, body }) => { + await need(me, "catalog:edit", catalogScope(params.id)); + const r = await productCall(() => catalogs.update(params.id, body)); + if (Object.keys(r.changed).length) await audited(me.id, "catalog.edit", params.id, { changed: r.changed }); + return { catalog: catalogView(r.catalog) }; + }, + { + signedIn: true, + params: catalogParam, + body: t.Object({ + name: t.Optional(t.String({ maxLength: 64 })), + url: t.Optional(t.String({ maxLength: 300 })), + contact: t.Optional(t.String({ maxLength: 200 })), + visibility: t.Optional(t.Union([t.Literal("public"), t.Literal("private")])), + membership: t.Optional(t.Union([t.Literal("open"), t.Literal("approval"), t.Literal("invite")])), + maxRating: t.Optional(t.Union([t.Literal("general"), t.Literal("adult")])), + uploads: t.Optional(t.Union([t.Literal("anyone"), t.Literal("members"), t.Literal("team")])), + mirrors: t.Optional(t.Union([t.Literal("auto"), t.Literal("approve"), t.Literal("off")])), + }), + }, + ) + .post( + "/catalogs/:id/key", + async ({ me, params }) => { + await need(me, "catalog:rotate-key", catalogScope(params.id)); + const key = await productCall(() => catalogs.rotateKey(params.id)); + await audited(me.id, "catalog.rotate_key", params.id); + return { key }; + }, + { signedIn: true, params: catalogParam }, + ) + .post( + "/catalogs/:id/suspend", + async ({ me, params, body }) => { + await need(me, "catalog:suspend", catalogScope(params.id)); + const c = await productCall(() => catalogs.setSuspended(params.id, true, body.reason.trim())); + await audited(me.id, "catalog.suspend", c.id, { name: c.name, reason: body.reason.trim() }); + return { catalog: catalogView(c) }; + }, + { signedIn: true, params: catalogParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) }, + ) + .post( + "/catalogs/:id/reinstate", + async ({ me, params }) => { + await need(me, "catalog:suspend", catalogScope(params.id)); + const c = await productCall(() => catalogs.setSuspended(params.id, false, "")); + await audited(me.id, "catalog.reinstate", c.id, { name: c.name }); + return { catalog: catalogView(c) }; + }, + { signedIn: true, params: catalogParam }, + ) + + // A catalog's team: the owner, and the roles it gave. + .get( + "/catalogs/:id/team", + async ({ me, params }) => { + await need(me, "audit:read", catalogScope(params.id)); + const team = await perms.team(params.id); + return { + ...team, + assignable: assignableCatalogRoles.map((r) => ({ + id: r, + name: roleBundles[r].name, + permissions: [...roleBundles[r].permissions], + })), + }; + }, + { signedIn: true, params: catalogParam }, + ) + .put( + "/catalogs/:id/team/:username", + async ({ me, params, body }) => { + const scope = catalogScope(params.id); + await need(me, "team:manage", scope); + if (!(await perms.mayGive(me, body.role, scope))) + throw new Problem(403, "forbidden", "You can only give roles whose permissions you have."); + const p = await personByUsername(params.username); + await productCall(() => catalogs.addRole(params.id, p.id, body.role, me.id)); + await audited(me.id, "catalog.team_add", params.id, { person: p.username, role: body.role }, { catalog: params.id, person: p.id }); + return { team: await perms.team(params.id) }; + }, + { + signedIn: true, + params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }), + body: t.Object({ role: t.Literal("catalog-operator") }), + }, + ) + .delete( + "/catalogs/:id/team/:username", + async ({ me, params, query }) => { + const scope = catalogScope(params.id); + const p = await personByUsername(params.username); + // Anyone may leave a team; taking others off needs team:manage. + if (p.id !== me.id) await need(me, "team:manage", scope); + await productCall(() => catalogs.removeRole(params.id, p.id, query.role)); + await audited(me.id, "catalog.team_remove", params.id, { person: p.username, role: query.role }, { catalog: params.id, person: p.id }); + return { team: await perms.team(params.id) }; + }, + { + signedIn: true, + params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }), + query: t.Object({ role: t.Literal("catalog-operator") }), + }, + ) + + // Membership: joining and leaving; the team's view and decisions; invitations. + .post( + "/catalogs/:id/join", + async ({ me, params, body }) => { + const invite = body?.invite || undefined; + const status = await productCall(() => catalogs.join(params.id, me.id, invite)); + await audited(me.id, status === "member" ? "catalog.join" : "catalog.request", params.id, invite ? { invite: true } : {}, { + catalog: params.id, + person: me.id, + }); + if (status === "member") realtime.notify(me.id, { type: "library" }); + return { membership: status }; + }, + { + signedIn: true, + params: catalogParam, + body: t.Optional(t.Object({ invite: t.Optional(t.String({ maxLength: 32 })) })), + }, + ) + .delete( + "/catalogs/:id/join", + async ({ me, params, set }) => { + await catalogs.leave(params.id, me.id); + await audited(me.id, "catalog.leave", params.id, {}, { catalog: params.id, person: me.id }); + realtime.notify(me.id, { type: "library" }); + set.status = 204; + }, + { signedIn: true, params: catalogParam }, + ) + .get( + "/catalogs/:id/members", + async ({ me, params, query }) => { + await need(me, "member:approve", catalogScope(params.id)); + return { members: await catalogs.members(params.id, query.status), counts: await catalogs.memberCounts(params.id) }; + }, + { + signedIn: true, + params: catalogParam, + query: t.Object({ + status: t.Optional(t.Union([t.Literal("requested"), t.Literal("member"), t.Literal("banned")])), + }), + }, + ) + .put( + "/catalogs/:id/members/:username", + async ({ me, params, body }) => { + await need(me, body.status === "banned" ? "member:ban" : "member:approve", catalogScope(params.id)); + const p = await personByUsername(params.username); + const before = await catalogs.membership(params.id, p.id); + await productCall(() => catalogs.setMember(params.id, p.id, body.status, me.id)); + const action = + body.status === "banned" ? "catalog.member_ban" : before === "requested" ? "catalog.member_approve" : "catalog.member_add"; + await audited(me.id, action, params.id, { person: p.username, ...(before ? { from: before } : {}) }, { + catalog: params.id, + person: p.id, + }); + realtime.notify(p.id, { type: "library" }); + return { membership: body.status }; + }, + { + signedIn: true, + params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }), + body: t.Object({ status: t.Union([t.Literal("member"), t.Literal("banned")]) }), + }, + ) + .delete( + "/catalogs/:id/members/:username", + async ({ me, params, set }) => { + await need(me, "member:remove", catalogScope(params.id)); + const p = await personByUsername(params.username); + const before = await catalogs.membership(params.id, p.id); + await productCall(() => catalogs.removeMember(params.id, p.id)); + const action = + before === "requested" ? "catalog.member_decline" : before === "banned" ? "catalog.member_unban" : "catalog.member_remove"; + await audited(me.id, action, params.id, { person: p.username }, { catalog: params.id, person: p.id }); + realtime.notify(p.id, { type: "library" }); + set.status = 204; + }, + { + signedIn: true, + params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }), + }, + ) + .get( + "/catalogs/:id/invites", + async ({ me, params }) => { + await need(me, "member:invite", catalogScope(params.id)); + return { invites: (await catalogs.invites(params.id)).map(inviteView) }; + }, + { signedIn: true, params: catalogParam }, + ) + .post( + "/catalogs/:id/invites", + async ({ me, params, body, set }) => { + await need(me, "member:invite", catalogScope(params.id)); + const inv = await productCall(() => catalogs.createInvite(params.id, me.id, body)); + await audited(me.id, "catalog.invite_create", params.id, { + maxUses: inv.maxUses, + expiresAt: inv.expiresAt?.toISOString() ?? null, + }); + set.status = 201; + return { invite: inviteView(inv) }; + }, + { + signedIn: true, + params: catalogParam, + body: t.Object({ + maxUses: t.Optional(t.Integer({ minimum: 1, maximum: 10_000 })), + expiresInDays: t.Optional(t.Integer({ minimum: 1, maximum: 365 })), + }), + }, + ) + .delete( + "/catalogs/:id/invites/:code", + async ({ me, params, set }) => { + await need(me, "member:invite", catalogScope(params.id)); + await productCall(() => catalogs.revokeInvite(params.id, params.code)); + await audited(me.id, "catalog.invite_revoke", params.id, { code: params.code.slice(0, 3) + "…" }); + set.status = 204; + }, + { + signedIn: true, + params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), code: t.String({ maxLength: 32 }) }), + }, + ) + + // What a catalog hosts, and delisting. + .get( + "/catalogs/:id/products", + async ({ me, params }) => { + await need(me, "audit:read", catalogScope(params.id)); + return { products: await catalogs.hosted(params.id) }; + }, + { signedIn: true, params: catalogParam }, + ) + .delete( + "/catalogs/:id/products/:product", + async ({ me, params, query }) => { + await need(me, "hosting:delist", catalogScope(params.id)); + const reason = query.reason.trim(); + const revisions = await productCall(() => catalogs.delist(params.id, params.product)); + await audited(me.id, "catalog.delist", params.id, { product: params.product, revisions, reason }, { + catalog: params.id, + product: params.product, + }); + return { revisions }; + }, + { + signedIn: true, + params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), product: t.String({ pattern: "^[a-z2-7]{10}$" }) }), + query: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }), + }, + ) + + // For catalogs: what to act on (files to delete, revisions to mirror, its status). A suspended catalog still + // reads it, to learn that it's suspended. + .get( + "/catalog/changes", + async ({ request, query }) => { + const c = await products.catalogFor(request.headers.get("authorization"), true); + if (!c) throw new Problem(401, "forbidden", "Not an approved catalog."); + return catalogs.changes(c.id, query.after ?? 0); + }, + { query: t.Object({ after: t.Optional(t.Numeric({ minimum: 0, multipleOf: 1 })) }) }, + ) + // For catalogs: registering an uploaded revision. .post( "/catalog/revisions", @@ -1170,6 +1637,14 @@ export function createApp({ inLibrary: lib !== undefined, stayOn: lib?.stayOn ?? null, mine: person?.id === p.product.creatorId, + available: person ? await products.available(person.id, p.product.id) : false, + catalogs: await Promise.all( + (await products.hostingCatalogs(p.product.id)).map(async (c) => ({ + ...c, + membership: c.membership as "open" | "approval" | "invite", + joined: person ? ((await catalogs.membership(c.id, person.id)) ?? null) : null, + })), + ), }; }, { params: productParam }, @@ -1262,7 +1737,14 @@ export function createApp({ .put( "/library/:id", async ({ me, params }) => { - await productCall(async () => products.add(me.id, params.id, await personAdult(me))); + try { + await products.add(me.id, params.id, await personAdult(me)); + } catch (e) { + if (e instanceof ProductError && e.slug === "members_only") + throw new Problem(e.status, e.slug, e.message, { catalogs: await products.hostingCatalogs(params.id) }); + if (e instanceof ProductError) throw new Problem(e.status, e.slug, e.message); + throw e; + } realtime.notify(me.id, { type: "library" }); return { ok: true }; }, @@ -1388,8 +1870,17 @@ export function createApp({ // The console. .get( "/console/catalogs", - async () => ({ catalogs: (await products.allCatalogs()).map(catalogView) }), - { moderator: true }, + async ({ me }) => { + const mod = isModerator(me); + const mine = await perms.teamCatalogs(me.id); + if (!mod && mine.length === 0) throw new Problem(403, "forbidden", "You're not on any catalog's team."); + const rows = mod ? await products.allCatalogs() : await db.select().from(catalog).where(inArray(catalog.id, mine)); + return { + catalogs: rows.map((c) => ({ ...catalogView(c), mine: c.ownerId === me.id, team: mine.includes(c.id) })), + moderator: mod, + }; + }, + { signedIn: true }, ) .post( "/console/catalogs/:id/:decision", @@ -1405,28 +1896,89 @@ export function createApp({ .get( "/console", async ({ me }) => { - const [counts] = await db - .select({ - people: sql`(select count(*) from ${user})::int`, - waiting: sql`(select count(*) from ${user} where not ${user.approved} and coalesce(${user.banned}, false) = false)::int`, - links: sql`(select count(*) from ${imvuLink})::int`, - computers: sql`(select count(*) from ${session} where ${session.userAgent} ilike 't5de-proxy%' and ${session.expiresAt} > now())::int`, - }) - .from(sql`(select 1) as one`); - const log = await db - .select({ at: audit.at, actor: user.username, action: audit.action, target: audit.target, detail: audit.detail }) - .from(audit) - .leftJoin(user, eq(user.id, audit.actorId)) - .orderBy(desc(audit.id)) - .limit(50); + const mod = isModerator(me); + const scope = await consoleScope(me); + const sections: ConsoleSection[] = ["overview"]; + if (mod || scope.products.length) sections.push("products"); + if (mod || scope.catalogs.length) sections.push("catalogs"); + if (mod) sections.push("people"); + if (sections.length > 1 || scope.rooms.length) sections.push("audit"); + if (me.role === "admin") sections.push("settings"); + if (sections.length === 1) + throw new Problem(403, "forbidden", "The console is for people who manage something on T5DE: a product, a catalog or a room."); + const [requests] = scope.catalogs.length + ? await db + .select({ n: count() }) + .from(schema.catalogMember) + .where(and(inArray(schema.catalogMember.catalogId, scope.catalogs), eq(schema.catalogMember.status, "requested"))) + : [{ n: 0 }]; + let moderation = null; + if (mod) { + const [counts] = await db + .select({ + people: sql`(select count(*) from ${user})::int`, + waiting: sql`(select count(*) from ${user} where not ${user.approved} and coalesce(${user.banned}, false) = false)::int`, + links: sql`(select count(*) from ${imvuLink})::int`, + computers: sql`(select count(*) from ${session} where ${session.userAgent} ilike 't5de-proxy%' and ${session.expiresAt} > now())::int`, + pendingCatalogs: sql`(select count(*) from ${catalog} where ${catalog.status} = 'pending')::int`, + }) + .from(sql`(select 1) as one`); + moderation = { counts: counts!, connections: realtime.connections(), online: await visibleOnline(me) }; + } return { - counts: counts!, - connections: realtime.connections(), - online: await visibleOnline(me), - audit: log.map((e) => ({ ...e, at: e.at.toISOString(), actor: e.actor ?? "" })), + sections, + mine: { products: scope.products.length, catalogs: scope.catalogs.length, rooms: scope.rooms.length }, + todo: { + memberRequests: requests?.n ?? 0, + pendingCatalogs: moderation?.counts.pendingCatalogs ?? 0, + waitingPeople: moderation?.counts.waiting ?? 0, + }, + moderation, + audit: await auditFor(me, scope, { limit: 20 }), }; }, - { moderator: true }, + { signedIn: true }, + ) + .get( + "/console/audit", + async ({ me, query }) => { + const scope = await consoleScope(me); + if (!isModerator(me) && !scope.catalogs.length && !scope.products.length && !scope.rooms.length) + throw new Problem(403, "forbidden", "The console is for people who manage something on T5DE."); + const entries = await auditFor(me, scope, query); + return { entries, before: entries.length ? entries[entries.length - 1]!.id : null }; + }, + { + signedIn: true, + query: t.Object({ + catalog: t.Optional(t.String({ maxLength: 32 })), + product: t.Optional(t.String({ maxLength: 32 })), + room: t.Optional(t.String({ maxLength: 32 })), + person: t.Optional(t.String({ maxLength: 64 })), + actor: t.Optional(t.String({ maxLength: 64 })), + action: t.Optional(t.String({ maxLength: 64 })), + q: t.Optional(t.String({ maxLength: 100 })), + from: t.Optional(t.String({ maxLength: 32 })), + to: t.Optional(t.String({ maxLength: 32 })), + before: t.Optional(t.Numeric({ minimum: 1, multipleOf: 1 })), + limit: t.Optional(t.Numeric({ minimum: 1, maximum: 200, multipleOf: 1 })), + }), + }, + ) + .get( + "/console/permissions", + async ({ me, query }) => { + const scope: Scope = + query.catalog ? { type: "catalog", id: query.catalog } : query.product ? { type: "product", id: query.product } : hubScope; + return { roles: await perms.rolesAt(me, scope), permissions: [...(await perms.at(me, scope))] }; + }, + { + signedIn: true, + query: t.Object({ + catalog: t.Optional(t.String({ pattern: "^[a-z2-7]{10}$" })), + product: t.Optional(t.String({ pattern: "^[a-z2-7]{10}$" })), + }), + }, ) .get( "/console/people", diff --git a/apps/api/src/catalogs.ts b/apps/api/src/catalogs.ts new file mode 100644 index 0000000..1ffdc84 --- /dev/null +++ b/apps/api/src/catalogs.ts @@ -0,0 +1,387 @@ +// Managing catalogs (docs/products.md, docs/console.md): settings, keys, teams, members, invites, what they host, +// suspension, and the changes feed catalogs poll. Permission checks are the routes'; this is what they do. +import { createHash } from "node:crypto"; +import { and, asc, count, desc, eq, gt, inArray, isNull, or, sql, schema, type Database } from "@t5de/db"; +import { ProductError } from "./products"; + +const { catalog, catalogChange, catalogInvite, catalogMember, hosting, product, productRevision, roleAssignment, user } = + schema; + +export type Catalog = typeof catalog.$inferSelect; +export type MemberStatus = "requested" | "member" | "banned"; + +const sha256 = (s: string) => createHash("sha256").update(s).digest("hex"); +const randomKey = () => Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString("base64url"); +// Invite codes: 10 letters and digits without look-alikes. +const inviteCode = () => { + const alphabet = "abcdefghjkmnpqrstuvwxyz23456789"; + return [...crypto.getRandomValues(new Uint8Array(10))].map((b) => alphabet[b % alphabet.length]).join(""); +}; + +const plain = (s: unknown, max: number) => { + const v = typeof s === "string" ? s.trim() : ""; + // eslint-disable-next-line no-control-regex + if (!v || v.length > max || /[\u0000-\u001f\u007f]/.test(v)) return undefined; + return v; +}; + +/** A catalog's address, checked and without a trailing slash (https, or localhost for development). */ +export function catalogURL(input: string): string { + let url: URL; + try { + url = new URL(input); + } catch { + throw new ProductError(400, "invalid", "That address isn't a URL."); + } + if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") + throw new ProductError(400, "invalid", "Catalogs need an https address."); + return url.origin + url.pathname.replace(/\/+$/, ""); +} + +export type CatalogSettings = { + name?: string; + url?: string; + contact?: string; + visibility?: "public" | "private"; + membership?: "open" | "approval" | "invite"; + maxRating?: "general" | "adult"; + uploads?: "anyone" | "members" | "team"; + mirrors?: "auto" | "approve" | "off"; +}; + +export class Catalogs { + constructor( + private readonly db: Database, + /** Whether a catalog answers at an address (its /health); checked when its address changes. */ + private readonly healthy: (url: string) => Promise = defaultHealthy, + ) {} + + async get(id: string): Promise { + const [c] = await this.db.select().from(catalog).where(eq(catalog.id, id)); + if (!c) throw new ProductError(404, "not_found", "No such catalog."); + return c; + } + + /** Changes a catalog's settings; returns the catalog and the fields that changed (old and new values). */ + async update(id: string, input: CatalogSettings) { + const c = await this.get(id); + const set: Partial = {}; + if (input.name !== undefined) { + const name = plain(input.name, 64); + if (!name) throw new ProductError(400, "invalid", "Names are 1-64 characters."); + set.name = name; + } + if (input.contact !== undefined) { + const contact = plain(input.contact, 200); + if (!contact) throw new ProductError(400, "invalid", "A takedown contact is needed."); + set.contact = contact; + } + if (input.url !== undefined) { + const url = catalogURL(input.url); + if (url !== c.url) { + const [taken] = await this.db.select({ id: catalog.id }).from(catalog).where(eq(catalog.url, url)); + if (taken) throw new ProductError(409, "taken", "A catalog at that address is already registered."); + if (!(await this.healthy(url))) + throw new ProductError(400, "unreachable", "Nothing answered at that address's /health. Is the catalog running there?"); + set.url = url; + } + } + for (const k of ["visibility", "membership", "maxRating", "uploads", "mirrors"] as const) + if (input[k] !== undefined) (set as Record)[k] = input[k]; + const changed: Record = {}; + for (const [k, v] of Object.entries(set)) { + const before = (c as Record)[k]; + if (before !== v) changed[k] = { from: before, to: v }; + } + if (Object.keys(changed).length === 0) return { catalog: c, changed }; + const [updated] = await this.db.update(catalog).set(set).where(eq(catalog.id, id)).returning(); + return { catalog: updated!, changed }; + } + + /** A new key for the catalog; the old one stops working at once. */ + async rotateKey(id: string): Promise { + await this.get(id); + const key = randomKey(); + await this.db.update(catalog).set({ keyHash: sha256(key) }).where(eq(catalog.id, id)); + return key; + } + + /** Suspends or reinstates an approved catalog; tells it through its changes feed. */ + async setSuspended(id: string, suspended: boolean, reason: string) { + const c = await this.get(id); + if (suspended && c.status !== "approved") + throw new ProductError(409, "not_approved", "Only an approved catalog can be suspended."); + if (!suspended && c.status !== "suspended") throw new ProductError(409, "not_suspended", "That catalog isn't suspended."); + const status = suspended ? "suspended" : "approved"; + const [updated] = await this.db + .update(catalog) + .set({ status, statusReason: suspended ? reason : "" }) + .where(eq(catalog.id, id)) + .returning(); + await this.change(id, { type: "status", status }); + return updated!; + } + + // ---- teams ---- + + async addRole(catalogId: string, personId: string, role: string, by: string) { + const c = await this.get(catalogId); + if (c.ownerId === personId) throw new ProductError(409, "owner", "That's the catalog's owner."); + await this.db + .insert(roleAssignment) + .values({ userId: personId, role, scopeType: "catalog", scopeId: catalogId, grantedBy: by }) + .onConflictDoNothing(); + } + + async removeRole(catalogId: string, personId: string, role: string) { + const gone = await this.db + .delete(roleAssignment) + .where( + and( + eq(roleAssignment.userId, personId), + eq(roleAssignment.role, role), + eq(roleAssignment.scopeType, "catalog"), + eq(roleAssignment.scopeId, catalogId), + ), + ) + .returning(); + if (gone.length === 0) throw new ProductError(404, "not_found", "They don't have that role here."); + } + + // ---- members ---- + + async membership(catalogId: string, personId: string): Promise { + const [m] = await this.db + .select({ status: catalogMember.status }) + .from(catalogMember) + .where(and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId))); + return m?.status as MemberStatus | undefined; + } + + /** + * A person joins: an open catalog at once, an approval one as a request, an invite one only with an invite (which + * also lets them into approval catalogs at once). Returns their status. + */ + async join(catalogId: string, personId: string, invite?: string): Promise { + const c = await this.get(catalogId); + if (c.status !== "approved") throw new ProductError(404, "not_found", "No such catalog."); + const now = await this.membership(catalogId, personId); + if (now === "banned") throw new ProductError(403, "banned", "This catalog's team banned you from it."); + if (now === "member") return "member"; + let status: MemberStatus; + let usedInvite: string | null = null; + if (invite) { + const updated = await this.db + .update(catalogInvite) + .set({ uses: sql`${catalogInvite.uses} + 1` }) + .where( + and( + eq(catalogInvite.code, invite), + eq(catalogInvite.catalogId, catalogId), + isNull(catalogInvite.revokedAt), + or(isNull(catalogInvite.expiresAt), gt(catalogInvite.expiresAt, new Date())), + or(isNull(catalogInvite.maxUses), sql`${catalogInvite.uses} < ${catalogInvite.maxUses}`), + ), + ) + .returning(); + if (updated.length === 0) throw new ProductError(410, "invite_invalid", "That invitation is used up, expired or revoked."); + status = "member"; + usedInvite = invite; + } else if (c.membership === "open") status = "member"; + else if (c.membership === "approval") status = "requested"; + else throw new ProductError(403, "invite_only", "This catalog takes members by invitation only."); + await this.db + .insert(catalogMember) + .values({ catalogId, userId: personId, status, invite: usedInvite }) + .onConflictDoUpdate({ target: [catalogMember.catalogId, catalogMember.userId], set: { status, invite: usedInvite } }); + return status; + } + + /** A person leaves (or withdraws their request). A ban stays. */ + async leave(catalogId: string, personId: string) { + await this.db + .delete(catalogMember) + .where( + and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId), sql`${catalogMember.status} <> 'banned'`), + ); + } + + async members(catalogId: string, status?: MemberStatus) { + const conds = [eq(catalogMember.catalogId, catalogId)]; + if (status) conds.push(eq(catalogMember.status, status)); + const rows = await this.db + .select({ + personId: catalogMember.userId, + username: user.username, + name: user.displayUsername, + status: catalogMember.status, + invite: catalogMember.invite, + since: catalogMember.updatedAt, + }) + .from(catalogMember) + .innerJoin(user, eq(user.id, catalogMember.userId)) + .where(and(...conds)) + .orderBy(desc(catalogMember.updatedAt)) + .limit(500); + return rows.map((r) => ({ + personId: r.personId, + username: r.name ?? r.username ?? "", + status: r.status as MemberStatus, + invited: r.invite !== null, + since: r.since.toISOString(), + })); + } + + async memberCounts(catalogId: string) { + const rows = await this.db + .select({ status: catalogMember.status, n: count() }) + .from(catalogMember) + .where(eq(catalogMember.catalogId, catalogId)) + .groupBy(catalogMember.status); + const get = (s: string) => rows.find((r) => r.status === s)?.n ?? 0; + return { members: get("member"), requests: get("requested"), banned: get("banned") }; + } + + /** The team sets a person's membership: let them in, or ban them. */ + async setMember(catalogId: string, personId: string, status: "member" | "banned", by: string) { + await this.get(catalogId); + await this.db + .insert(catalogMember) + .values({ catalogId, userId: personId, status, decidedBy: by }) + .onConflictDoUpdate({ target: [catalogMember.catalogId, catalogMember.userId], set: { status, decidedBy: by } }); + } + + async removeMember(catalogId: string, personId: string) { + const gone = await this.db + .delete(catalogMember) + .where(and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId))) + .returning(); + if (gone.length === 0) throw new ProductError(404, "not_found", "They aren't a member, and haven't asked to be."); + } + + // ---- invites ---- + + async createInvite(catalogId: string, by: string, opts: { maxUses?: number; expiresInDays?: number }) { + await this.get(catalogId); + const [inv] = await this.db + .insert(catalogInvite) + .values({ + code: inviteCode(), + catalogId, + createdBy: by, + maxUses: opts.maxUses ?? null, + expiresAt: opts.expiresInDays ? new Date(Date.now() + opts.expiresInDays * 86_400_000) : null, + }) + .returning(); + return inv!; + } + + async invites(catalogId: string) { + return this.db + .select() + .from(catalogInvite) + .where(and(eq(catalogInvite.catalogId, catalogId), isNull(catalogInvite.revokedAt))) + .orderBy(desc(catalogInvite.createdAt)); + } + + async revokeInvite(catalogId: string, code: string) { + const r = await this.db + .update(catalogInvite) + .set({ revokedAt: new Date() }) + .where(and(eq(catalogInvite.catalogId, catalogId), eq(catalogInvite.code, code), isNull(catalogInvite.revokedAt))) + .returning(); + if (r.length === 0) throw new ProductError(404, "not_found", "No such invitation."); + } + + // ---- what it hosts ---- + + /** The products a catalog hosts, newest first, with the revisions it hosts of each. */ + async hosted(catalogId: string) { + const rows = await this.db + .select({ + id: product.id, + name: product.name, + rating: product.rating, + creator: user.displayUsername, + creatorUsername: user.username, + removed: product.removedAt, + deleted: product.deletedAt, + revisions: sql`array_agg(${hosting.number} order by ${hosting.number})`, + since: sql`min(${hosting.createdAt})`, + }) + .from(hosting) + .innerJoin(product, eq(product.id, hosting.productId)) + .innerJoin(user, eq(user.id, product.creatorId)) + .where(eq(hosting.catalogId, catalogId)) + .groupBy(product.id, user.id) + .orderBy(desc(sql`min(${hosting.createdAt})`)) + .limit(500); + return rows.map((r) => ({ + id: r.id, + name: r.name, + rating: r.rating, + creator: r.creator ?? r.creatorUsername ?? "", + removed: r.removed !== null, + deleted: r.deleted !== null, + revisions: r.revisions, + since: new Date(r.since).toISOString(), + })); + } + + async hostedCounts(catalogId: string) { + const [r] = await this.db + .select({ + products: sql`count(distinct ${hosting.productId})::int`, + revisions: sql`count(*)::int`, + bytes: sql`coalesce(sum((select sum((f->>'size')::bigint) from jsonb_array_elements(${productRevision.files}) f)), 0)::bigint`, + }) + .from(hosting) + .innerJoin( + productRevision, + and(eq(productRevision.productId, hosting.productId), eq(productRevision.number, hosting.number)), + ) + .where(eq(hosting.catalogId, catalogId)); + return { products: r?.products ?? 0, revisions: r?.revisions ?? 0, bytes: Number(r?.bytes ?? 0) }; + } + + /** The catalog stops hosting every revision of a product; its files go in the changes feed for deletion. */ + async delist(catalogId: string, productId: string) { + const gone = await this.db + .delete(hosting) + .where(and(eq(hosting.catalogId, catalogId), eq(hosting.productId, productId))) + .returning(); + if (gone.length === 0) throw new ProductError(404, "not_found", "This catalog doesn't host that product."); + const revs = await this.db + .select({ number: productRevision.number, files: productRevision.files }) + .from(productRevision) + .where(and(eq(productRevision.productId, productId), inArray(productRevision.number, gone.map((g) => g.number)))); + for (const r of revs) await this.change(catalogId, { type: "delete", product: productId, revision: r.number, files: r.files }); + return gone.map((g) => g.number); + } + + // ---- the changes feed ---- + + async change(catalogId: string, data: Record) { + await this.db.insert(catalogChange).values({ catalogId, data }); + } + + /** Changes after a cursor, oldest first (at most 500 at a time). */ + async changes(catalogId: string, after: number) { + const rows = await this.db + .select() + .from(catalogChange) + .where(and(eq(catalogChange.catalogId, catalogId), gt(catalogChange.id, after))) + .orderBy(asc(catalogChange.id)) + .limit(500); + return { changes: rows.map((r) => r.data), cursor: rows.length ? rows[rows.length - 1]!.id : after }; + } +} + +async function defaultHealthy(url: string): Promise { + try { + const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5000) }); + return res.ok; + } catch { + return false; + } +} diff --git a/apps/api/src/permissions.ts b/apps/api/src/permissions.ts new file mode 100644 index 0000000..f1e91ee --- /dev/null +++ b/apps/api/src/permissions.ts @@ -0,0 +1,259 @@ +// Permissions (docs/console.md): code checks `resource:action` permissions, never roles. A role is a bundle of +// permissions given at a scope; hub-wide roles are user.role, owners are derived (a catalog's owner, a product's +// creator), and other roles are role_assignment rows. +import { and, eq, schema, type Database } from "@t5de/db"; + +const { catalog, product, roleAssignment, user } = schema; + +export const permissions = [ + "product:edit", + "product:edit-locked", + "product:lock", + "product:rate-lower", + "product:publish", + "product:delete", + "product:restore", + "product:remove", + "product:unremove", + "product:transfer", + "revision:upload", + "revision:retract", + "catalog:approve", + "catalog:edit", + "catalog:rotate-key", + "catalog:suspend", + "catalog:delete-request", + "catalog:delete-review", + "catalog:transfer", + "team:manage", + "member:approve", + "member:invite", + "member:remove", + "member:ban", + "hosting:choose", + "hosting:delist", + "import:grant", + "import:grant-on-behalf", + "import:accept", + "import:run", + "import:pause", + "import:withdraw", + "case:read", + "case:resolve", + "case:assign", + "audit:read", + "audit:read-sensitive", + "stats:read", + "person:approve", + "person:ban", + "person:link-cap", + "person:notes", + "person:role", + "person:view-as", + "room:edit", + "room:close", + "room:kick", + "room:ban", + "room:mute", + "settings:edit", +] as const; + +export type Permission = (typeof permissions)[number]; + +/** Where a permission applies. The hub covers everything. */ +export type Scope = { type: "hub" } | { type: "catalog"; id: string } | { type: "product"; id: string }; + +export const hubScope: Scope = { type: "hub" }; + +const all = (prefix: string) => permissions.filter((p) => p.startsWith(prefix + ":")); +const without = (list: readonly Permission[], ...drop: Permission[]) => list.filter((p) => !drop.includes(p)); + +const catalogOwner: Permission[] = [ + "catalog:edit", + "catalog:rotate-key", + "catalog:delete-request", + "catalog:transfer", + "team:manage", + ...all("member"), + "hosting:delist", + "import:grant", + "import:accept", + "import:run", + "import:pause", + "import:withdraw", + "case:read", + "case:resolve", + "audit:read", + "stats:read", +]; + +/** The built-in roles: name, the scope they're given at, and their permissions. */ +export const roles = { + admin: { name: "Admin", scope: "hub", permissions: [...permissions] as Permission[] }, + moderator: { + name: "Moderator", + scope: "hub", + permissions: [ + ...without(all("product"), "product:transfer"), + "revision:retract", + "catalog:approve", + "catalog:suspend", + "catalog:delete-review", + "hosting:delist", + "import:grant-on-behalf", + "import:withdraw", + "import:pause", + ...all("case"), + "person:approve", + "person:ban", + "person:link-cap", + "person:notes", + "audit:read", + "stats:read", + ...all("room"), + ] as Permission[], + }, + "catalog-owner": { name: "Owner", scope: "catalog", permissions: catalogOwner }, + "catalog-operator": { + name: "Operator", + scope: "catalog", + permissions: without(catalogOwner, "catalog:delete-request", "catalog:transfer", "team:manage"), + }, + creator: { + name: "Creator", + scope: "product", + permissions: [ + "product:edit", + "product:publish", + "product:delete", + "product:restore", + "product:transfer", + "revision:upload", + "revision:retract", + "hosting:choose", + "case:read", + "audit:read", + "stats:read", + ] as Permission[], + }, +} as const satisfies Record; + +export type RoleId = keyof typeof roles; + +/** Roles that can be given at a catalog (the owner is derived, never given). */ +export const assignableCatalogRoles = ["catalog-operator"] as const; + +/** A person, as the check needs them. */ +export type Who = { id: string; role: string }; + +/** Thrown when a permission is missing; the API answers 403 with what was missing. */ +export class Forbidden extends Error { + constructor( + readonly permission: Permission, + readonly scope: Scope, + ) { + super("You don't have permission to do that."); + } +} + +export class Permissions { + constructor(private readonly db: Database) {} + + /** A person's hub-wide permissions (their user.role). */ + hub(who: Who | undefined): Set { + if (who?.role === "admin") return new Set(roles.admin.permissions); + if (who?.role === "moderator") return new Set(roles.moderator.permissions); + return new Set(); + } + + /** The roles a person holds at a scope, broader scopes' included ("admin", "catalog-owner", ...). */ + async rolesAt(who: Who | undefined, scope: Scope): Promise { + if (!who) return []; + const out: RoleId[] = []; + if (who.role === "admin") out.push("admin"); + else if (who.role === "moderator") out.push("moderator"); + if (scope.type === "catalog") { + const [c] = await this.db.select({ ownerId: catalog.ownerId }).from(catalog).where(eq(catalog.id, scope.id)); + if (c?.ownerId === who.id) out.push("catalog-owner"); + const given = await this.db + .select({ role: roleAssignment.role }) + .from(roleAssignment) + .where( + and(eq(roleAssignment.userId, who.id), eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, scope.id)), + ); + for (const g of given) if (g.role in roles) out.push(g.role as RoleId); + } else if (scope.type === "product") { + const [p] = await this.db.select({ creatorId: product.creatorId }).from(product).where(eq(product.id, scope.id)); + if (p?.creatorId === who.id) out.push("creator"); + } + return out; + } + + /** Every permission a person holds at a scope. */ + async at(who: Who | undefined, scope: Scope): Promise> { + const out = new Set(); + for (const r of await this.rolesAt(who, scope)) for (const p of roles[r].permissions) out.add(p); + return out; + } + + async can(who: Who | undefined, permission: Permission, scope: Scope): Promise { + if (this.hub(who).has(permission)) return true; + if (scope.type === "hub") return false; + return (await this.at(who, scope)).has(permission); + } + + /** Throws Forbidden unless the person holds the permission at the scope. */ + async require(who: Who | undefined, permission: Permission, scope: Scope): Promise { + if (!(await this.can(who, permission, scope))) throw new Forbidden(permission, scope); + } + + /** May `who` give (or take away) `role` at `scope`? Only if they hold every permission in it there. */ + async mayGive(who: Who | undefined, role: RoleId, scope: Scope): Promise { + const held = await this.at(who, scope); + return roles[role].permissions.every((p) => held.has(p)); + } + + /** The catalogs a person is on the team of (owner or a role there). */ + async teamCatalogs(personId: string): Promise { + const owned = await this.db.select({ id: catalog.id }).from(catalog).where(eq(catalog.ownerId, personId)); + const given = await this.db + .select({ id: roleAssignment.scopeId }) + .from(roleAssignment) + .where(and(eq(roleAssignment.userId, personId), eq(roleAssignment.scopeType, "catalog"))); + return [...new Set([...owned.map((c) => c.id), ...given.map((g) => g.id)])]; + } + + /** The products a person created. */ + async ownProducts(personId: string): Promise { + return (await this.db.select({ id: product.id }).from(product).where(eq(product.creatorId, personId))).map((p) => p.id); + } + + /** A catalog's team: the owner and everyone given a role there, with their names. */ + async team(catalogId: string) { + const [c] = await this.db + .select({ ownerId: catalog.ownerId, name: user.displayUsername, username: user.username }) + .from(catalog) + .innerJoin(user, eq(user.id, catalog.ownerId)) + .where(eq(catalog.id, catalogId)); + const given = await this.db + .select({ + personId: roleAssignment.userId, + role: roleAssignment.role, + name: user.displayUsername, + username: user.username, + since: roleAssignment.createdAt, + }) + .from(roleAssignment) + .innerJoin(user, eq(user.id, roleAssignment.userId)) + .where(and(eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, catalogId))); + return { + owner: c ? { personId: c.ownerId, username: c.name ?? c.username ?? "" } : undefined, + roles: given.map((g) => ({ + personId: g.personId, + role: g.role, + username: g.name ?? g.username ?? "", + since: g.since.toISOString(), + })), + }; + } +} diff --git a/apps/api/src/products.ts b/apps/api/src/products.ts index ce0f40c..e085ff1 100644 --- a/apps/api/src/products.ts +++ b/apps/api/src/products.ts @@ -20,7 +20,7 @@ import { cleanHTML, maxDescription } from "./html"; import type { ImvuClient } from "./imvu"; import type { Signer } from "./keys"; -const { catalog, product, productRevision, hosting, library, user } = schema; +const { catalog, catalogMember, product, productRevision, hosting, library, roleAssignment, user } = schema; export type Catalog = typeof catalog.$inferSelect; export type Product = typeof product.$inferSelect; @@ -74,6 +74,21 @@ const sameFiles = (a: FileEntry[], b: FileEntry[]) => // A parent's info another revision looked up lately is reused rather than asking IMVU again. const parentInfoFresh = 7 * 24 * 60 * 60 * 1000; +/** + * Whether a product is available to a person (docs/products.md, "Member"): they made it, or a catalog hosting it is + * approved, hasn't banned them, and is open or has them as a member or on its team. + */ +export const availableTo = (personId: string) => sql`( + ${product.creatorId} = ${personId} or exists ( + select 1 from ${hosting} h + join ${catalog} c on c.id = h.catalog_id + left join ${catalogMember} m on m.catalog_id = c.id and m.user_id = ${personId} + where h.product_id = ${product.id} and c.status = 'approved' and coalesce(m.status, '') <> 'banned' + and (c.membership = 'open' or m.status = 'member' or c.owner_id = ${personId} + or exists (select 1 from ${roleAssignment} r + where r.user_id = ${personId} and r.scope_type = 'catalog' and r.scope_id = c.id)) + ))`; + /** Each product's latest revision (joined on product.latest, for lists). */ const latestJoin = and(eq(productRevision.productId, product.id), eq(productRevision.number, product.latest)); @@ -123,12 +138,13 @@ export class Products { return { catalog: c!, key }; } - /** The catalog an `Authorization: Catalog :` header names, if it's approved and the key is right. */ - async catalogFor(header: string | null): Promise { + /** The catalog an `Authorization: Catalog :` header names, if it's approved (or suspended, when asked) + * and the key is right. */ + async catalogFor(header: string | null, suspendedToo = false): Promise { const m = header?.match(/^Catalog ([a-z2-7]{10}):(\S+)$/); if (!m) return undefined; const [c] = await this.db.select().from(catalog).where(eq(catalog.id, m[1]!)); - if (!c || c.status !== "approved") return undefined; + if (!c || !(c.status === "approved" || (suspendedToo && c.status === "suspended"))) return undefined; return timingSafeEqual(Buffer.from(sha256(m[2]!)), Buffer.from(c.keyHash)) ? c : undefined; } @@ -163,6 +179,23 @@ export class Products { const [c] = await this.db.select().from(catalog).where(eq(catalog.id, catalogId)); if (!c || c.status !== "approved") throw new ProductError(404, "not_found", "No such catalog."); if (productId) await this.own(personId, productId); + if (c.uploads !== "anyone" && c.ownerId !== personId) { + const [team] = await this.db + .select({ role: roleAssignment.role }) + .from(roleAssignment) + .where(and(eq(roleAssignment.userId, personId), eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, c.id))); + const [m] = await this.db + .select({ status: catalogMember.status }) + .from(catalogMember) + .where(and(eq(catalogMember.catalogId, c.id), eq(catalogMember.userId, personId))); + const ok = team !== undefined || (c.uploads === "members" && m?.status === "member"); + if (!ok) + throw new ProductError( + 403, + "forbidden", + c.uploads === "members" ? "Only this catalog's members can upload to it." : "Only this catalog's team can upload to it.", + ); + } const token = await this.signer.sign( "t5de-upload", { sub: personId, catalog: c.id, ...(productId ? { product: productId } : {}) }, @@ -183,6 +216,8 @@ export class Products { if (!files) throw new ProductError(400, "invalid", "The file list isn't valid."); if (body.rating !== "general" && body.rating !== "adult") throw new ProductError(400, "invalid", "The rating must be general or adult."); + if (body.rating === "adult" && c.maxRating === "general") + throw new ProductError(400, "rating_ceiling", "This catalog hosts general-audience products only."); const parent = Number.isSafeInteger(body.parent) && body.parent > 0 ? body.parent : 0; const notes = (typeof body.notes === "string" ? body.notes.trim() : "").slice(0, 2000); const personId = claims.sub; @@ -427,7 +462,7 @@ export class Products { .innerJoin(product, eq(product.id, library.productId)) .innerJoin(user, eq(user.id, product.creatorId)) .leftJoin(productRevision, latestJoin) - .where(and(eq(library.userId, personId), isNull(product.removedAt))) + .where(and(eq(library.userId, personId), isNull(product.removedAt), availableTo(personId))) .orderBy(desc(library.addedAt)); } @@ -443,9 +478,28 @@ export class Products { const p = await this.get(productId, adult); if (p.product.deletedAt && !(await this.inLibrary(personId, productId))) throw new ProductError(410, "deleted", "Its creator deleted this product."); + if (!(await this.available(personId, productId))) + throw new ProductError(403, "members_only", "Join a catalog hosting this product to get it."); await this.db.insert(library).values({ userId: personId, productId }).onConflictDoNothing(); } + async available(personId: string, productId: string): Promise { + const [row] = await this.db + .select({ id: product.id }) + .from(product) + .where(and(eq(product.id, productId), availableTo(personId))); + return row !== undefined; + } + + /** The approved catalogs hosting a product, with their membership rule (for "join to get it"). */ + async hostingCatalogs(productId: string) { + return this.db + .selectDistinct({ id: catalog.id, name: catalog.name, membership: catalog.membership, visibility: catalog.visibility }) + .from(hosting) + .innerJoin(catalog, eq(catalog.id, hosting.catalogId)) + .where(and(eq(hosting.productId, productId), eq(catalog.status, "approved"))); + } + async remove(personId: string, productId: string) { await this.db.delete(library).where(and(eq(library.userId, personId), eq(library.productId, productId))); } diff --git a/apps/api/src/realtime.ts b/apps/api/src/realtime.ts index 583f39d..7f38ff5 100644 --- a/apps/api/src/realtime.ts +++ b/apps/api/src/realtime.ts @@ -121,8 +121,15 @@ export type RealtimeDeps = { hostedVisit?: (userId: string, n: number, imvuId: number) => void; /** The IMVU username of a linked account, from its verified link ("" if unknown). */ accountName?: (imvuId: number) => Promise; + /** + * Of these hub product IDs, the ones a person may wear (available to them: docs/products.md, "Member"). Outfit + * items with other hub IDs aren't relayed; items that aren't hub IDs (local packs) are. + */ + wearable?: (userId: string, productIds: string[]) => Promise>; }; +const hubProductID = /^[a-z2-7]{10}$/; + const maxChats = 16; const chatPattern = /^[0-9]{1,20}$/; const roomPattern = /^[0-9]{1,12}-[0-9]{1,12}$/; @@ -179,6 +186,7 @@ export class Realtime { hostedBoot: async () => {}, hostedVisit: () => {}, accountName: async () => "", + wearable: async (_, ids) => new Set(ids), ...d, }; } @@ -644,10 +652,16 @@ export class Realtime { const items = m.items ?? []; if (!Array.isArray(items)) return { type: "error", message: "items must be a list" }; if (items.length > 64) return { type: "error", message: "too many items" }; - s.outfit = items + let 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, adult: it.adult === true })); + const hubIds = outfit.filter((it) => hubProductID.test(it.id)).map((it) => it.id); + if (hubIds.length > 0) { + const ok = await this.deps.wearable(s.userId, hubIds); + outfit = outfit.filter((it) => !hubProductID.test(it.id) || ok.has(it.id)); + } + s.outfit = outfit; this.changed(s.chats.keys()); return; } diff --git a/apps/api/test/catalogs.test.ts b/apps/api/test/catalogs.test.ts new file mode 100644 index 0000000..a35eb80 --- /dev/null +++ b/apps/api/test/catalogs.test.ts @@ -0,0 +1,338 @@ +// Managing catalogs (docs/products.md) with permissions (docs/console.md): settings, keys, teams, membership and +// what it gates, invites, delisting, suspension, the changes feed, and the console's scoped audit log. +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { openDatabase, 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 apiURL = "http://api.test"; +const accounts = new Map(); +const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + if (url.pathname.startsWith("/product/")) return Response.json({ status: "failure" }, { status: 404 }); + 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; + +// Catalog addresses that answer /health (for address changes). +const healthy = new Set(["https://moved.example.test"]); + +let db: Database; +let close: () => Promise; +let base: string; +let stop: () => void; + +beforeAll(async () => { + ({ db, close } = await openDatabase("memory")); + const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false }); + const hub = createApp({ + db, + auth, + imvu: new ImvuClient("http://imvu.test", fakeIMVU), + apiURL, + webURL, + catalogHealthy: async (url) => healthy.has(url), + }); + 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; name: string }; +async function call(p: Person | undefined, method: string, path: string, body?: unknown, headers: Record = {}) { + const res = await fetch(base + path, { + method, + headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}), ...headers }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json: any; + try { + json = JSON.parse(text); + } catch { + json = text; + } + return { status: res.status, json }; +} + +async function person(name: string): 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 }), + }); + return { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "), name }; +} + +const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n }); + +/** A person's catalog, registered and approved; its key's Authorization header. */ +async function catalogOf(owner: Person, admin: Person, name: string, membership = "open") { + const reg = await call(owner, "POST", "/api/v1/catalogs", { + name, + url: `https://${name.toLowerCase().replace(/\W/g, "")}.example.test`, + contact: "takedowns@example.test", + visibility: "public", + membership, + }); + expect(reg.status).toBe(201); + const id = reg.json.catalog.id as string; + expect((await call(admin, "POST", `/api/v1/console/catalogs/${id}/approve`)).status).toBe(200); + return { id, auth: { Authorization: `Catalog ${id}:${reg.json.key}` } }; +} + +/** A product uploaded by a person to a catalog; its ID. */ +async function upload(maker: Person, c: { id: string; auth: Record }, name: string, n: number, rating = "general") { + const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id }); + expect(tok.status).toBe(200); + const r = await call( + undefined, + "POST", + "/api/v1/catalog/revisions", + { uploadToken: tok.json.token, files: [file("index.xml", n)], rating, parent: 0, name, notes: "" }, + c.auth, + ); + expect(r.status).toBe(201); + return r.json.product as string; +} + +let admin: Person; + +test("the first account is the admin", async () => { + admin = await person("admin1"); + expect((await call(admin, "GET", "/api/v1/me")).json.person.role).toBe("admin"); +}); + +test("a catalog's owner changes its settings and key; others can't, and are told which permission they lack", async () => { + const owner = await person("owner1"); + const stranger = await person("stranger1"); + const c = await catalogOf(owner, admin, "Owner Shelf"); + + // Anyone can see an approved catalog; the owner sees their permissions there. + const seen = await call(owner, "GET", `/api/v1/catalogs/${c.id}`); + expect(seen.status).toBe(200); + expect(seen.json.roles).toContain("catalog-owner"); + expect(seen.json.permissions).toContain("catalog:edit"); + expect((await call(stranger, "GET", `/api/v1/catalogs/${c.id}`)).json.permissions).toEqual([]); + + // A stranger is refused, naming what's missing. + const refused = await call(stranger, "PATCH", `/api/v1/catalogs/${c.id}`, { name: "Mine now" }); + expect(refused.status).toBe(403); + expect(refused.json).toMatchObject({ error: "forbidden", missing: { permission: "catalog:edit", scope: { catalog: c.id } } }); + + // The owner changes settings; an address must answer /health. + const edited = await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { name: "Owner's Shelf", maxRating: "general", uploads: "members" }); + expect(edited.status).toBe(200); + expect(edited.json.catalog).toMatchObject({ name: "Owner's Shelf", maxRating: "general", uploads: "members" }); + expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { url: "https://nowhere.example.test" })).json.error).toBe("unreachable"); + expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { url: "https://moved.example.test/" })).json.catalog.url).toBe( + "https://moved.example.test", + ); + + // A new key: the old one stops working at once. + const rotated = await call(owner, "POST", `/api/v1/catalogs/${c.id}/key`); + expect(rotated.status).toBe(200); + expect((await call(undefined, "GET", "/api/v1/catalog/changes", undefined, c.auth)).status).toBe(401); + expect( + (await call(undefined, "GET", "/api/v1/catalog/changes", undefined, { Authorization: `Catalog ${c.id}:${rotated.json.key}` })).status, + ).toBe(200); +}); + +test("teams: the owner gives the operator role; operators manage members but not the team", async () => { + const owner = await person("owner2"); + const op = await person("op2"); + const other = await person("other2"); + const c = await catalogOf(owner, admin, "Team Shelf", "approval"); + + expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/team/other2`, { role: "catalog-operator" })).status).toBe(403); + const added = await call(owner, "PUT", `/api/v1/catalogs/${c.id}/team/op2`, { role: "catalog-operator" }); + expect(added.status).toBe(200); + expect(added.json.team.roles).toMatchObject([{ username: "op2", role: "catalog-operator" }]); + + // The operator holds member:approve, not team:manage. + const perms = (await call(op, "GET", `/api/v1/console/permissions?catalog=${c.id}`)).json; + expect(perms.roles).toEqual(["catalog-operator"]); + expect(perms.permissions).toContain("member:approve"); + expect(perms.permissions).not.toContain("team:manage"); + expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/team/other2`, { role: "catalog-operator" })).json.missing.permission).toBe( + "team:manage", + ); + + // An approval catalog: other asks, the operator approves. + expect((await call(other, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.membership).toBe("requested"); + const requests = await call(op, "GET", `/api/v1/catalogs/${c.id}/members?status=requested`); + expect(requests.json.members).toMatchObject([{ username: "other2", status: "requested" }]); + expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/members/other2`, { status: "member" })).status).toBe(200); + expect((await call(other, "GET", `/api/v1/catalogs/${c.id}`)).json.membership).toBe("member"); + + // Operators leave on their own; the console lists the catalog for them while they're on the team. + expect((await call(op, "GET", "/api/v1/console/catalogs")).json.catalogs.map((x: any) => x.id)).toEqual([c.id]); + expect((await call(op, "DELETE", `/api/v1/catalogs/${c.id}/team/op2?role=catalog-operator`)).status).toBe(200); + expect((await call(op, "GET", "/api/v1/console/catalogs")).status).toBe(403); +}); + +test("membership gates getting and wearing products; seeing them doesn't need it", async () => { + const owner = await person("owner3"); + const maker = await person("maker3"); + const fan = await person("fan3"); + const c = await catalogOf(owner, admin, "Invite Shelf", "invite"); + // Uploads to an invite catalog: anyone may upload by default. + const pid = await upload(maker, c, "Hat", 31); + + // Seeing works; getting doesn't, and says which catalogs host it. + const page = await call(fan, "GET", `/api/v1/products/${pid}`); + expect(page.json).toMatchObject({ available: false, catalogs: [{ id: c.id, membership: "invite", joined: null }] }); + expect((await call(fan, "GET", `/api/v1/products/${pid}/files`)).status).toBe(200); + const refused = await call(fan, "PUT", `/api/v1/library/${pid}`); + expect(refused.status).toBe(403); + expect(refused.json).toMatchObject({ error: "members_only", catalogs: [{ id: c.id }] }); + + // Invite only: joining without an invite is refused; with one, they're in. + expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.error).toBe("invite_only"); + const inv = await call(owner, "POST", `/api/v1/catalogs/${c.id}/invites`, { maxUses: 1 }); + expect(inv.status).toBe(201); + expect(inv.json.invite.url).toBe(`${webURL}/catalogs/${c.id}?invite=${inv.json.invite.code}`); + expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, { invite: inv.json.invite.code })).json.membership).toBe("member"); + const late = await person("late3"); + expect((await call(late, "POST", `/api/v1/catalogs/${c.id}/join`, { invite: inv.json.invite.code })).json.error).toBe("invite_invalid"); + + expect((await call(fan, "PUT", `/api/v1/library/${pid}`)).status).toBe(200); + expect((await call(fan, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([pid]); + + // Banned: the product stays in the table but isn't listed (or wearable) until they're back. + expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/members/fan3`, { status: "banned" })).status).toBe(200); + expect((await call(fan, "GET", "/api/v1/library")).json.library).toEqual([]); + expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.error).toBe("banned"); + expect((await call(owner, "DELETE", `/api/v1/catalogs/${c.id}/members/fan3`)).status).toBe(204); + expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/members/fan3`, { status: "member" })).status).toBe(200); + expect((await call(fan, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([pid]); + + // Its creator always has their own product. + expect((await call(maker, "PUT", `/api/v1/library/${pid}`)).status).toBe(200); +}); + +test("a catalog's upload policy and rating ceiling apply to uploads", async () => { + const owner = await person("owner4"); + const maker = await person("maker4"); + const c = await catalogOf(owner, admin, "Strict Shelf"); + expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { uploads: "members", maxRating: "general" })).status).toBe(200); + + const refused = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id }); + expect(refused).toMatchObject({ status: 403, json: { error: "forbidden" } }); + expect((await call(maker, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.membership).toBe("member"); + const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id }); + expect(tok.status).toBe(200); + const adult = await call( + undefined, + "POST", + "/api/v1/catalog/revisions", + { uploadToken: tok.json.token, files: [file("index.xml", 41)], rating: "adult", parent: 0, name: "Spicy", notes: "" }, + c.auth, + ); + expect(adult).toMatchObject({ status: 400, json: { error: "rating_ceiling" } }); + + // Team only: members can't either. + expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { uploads: "team" })).status).toBe(200); + expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(403); + expect((await call(owner, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(200); +}); + +test("delisting and suspension reach the catalog through its changes feed", async () => { + const owner = await person("owner5"); + const maker = await person("maker5"); + const c = await catalogOf(owner, admin, "Feed Shelf"); + const pid = await upload(maker, c, "Scarf", 51); + expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}/products`)).json.products).toMatchObject([{ id: pid, revisions: [1] }]); + + expect((await call(maker, "DELETE", `/api/v1/catalogs/${c.id}/products/${pid}?reason=no`)).status).toBe(403); + const delisted = await call(owner, "DELETE", `/api/v1/catalogs/${c.id}/products/${pid}?reason=${encodeURIComponent("off-topic")}`); + expect(delisted.json.revisions).toEqual([1]); + expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}/products`)).json.products).toEqual([]); + + const feed = await call(undefined, "GET", "/api/v1/catalog/changes", undefined, c.auth); + expect(feed.json.changes).toEqual([{ type: "delete", product: pid, revision: 1, files: [file("index.xml", 51)] }]); + const cursor = feed.json.cursor; + expect((await call(undefined, "GET", `/api/v1/catalog/changes?after=${cursor}`, undefined, c.auth)).json.changes).toEqual([]); + + // Owners can't suspend their own catalog; moderators can. A suspended catalog can't register revisions, still + // reads its feed, and is told. + expect((await call(owner, "POST", `/api/v1/catalogs/${c.id}/suspend`, { reason: "x" })).status).toBe(403); + expect((await call(admin, "POST", `/api/v1/catalogs/${c.id}/suspend`, { reason: "spam" })).json.catalog).toMatchObject({ + status: "suspended", + statusReason: "spam", + }); + expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(404); + const told = await call(undefined, "GET", `/api/v1/catalog/changes?after=${cursor}`, undefined, c.auth); + expect(told.json.changes).toEqual([{ type: "status", status: "suspended" }]); + expect((await call(admin, "POST", `/api/v1/catalogs/${c.id}/reinstate`)).json.catalog.status).toBe("approved"); +}); + +test("the console is for everyone who controls something; its audit log shows only what they may see", async () => { + const owner = await person("owner6"); + const maker = await person("maker6"); + const nobody = await person("nobody6"); + const c = await catalogOf(owner, admin, "Audit Shelf"); + const other = await catalogOf(await person("owner6b"), admin, "Other Shelf"); + const pid = await upload(maker, c, "Gloves", 61); + expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { contact: "legal@example.test" })).status).toBe(200); + expect((await call(maker, "PATCH", `/api/v1/products/${pid}`, { name: "Warm Gloves" })).status).toBe(200); + expect((await call(admin, "PATCH", `/api/v1/catalogs/${other.id}`, { name: "Other Shelf 2" })).status).toBe(200); + + // Sections by what they control. + expect((await call(nobody, "GET", "/api/v1/console")).status).toBe(403); + expect((await call(nobody, "GET", "/api/v1/console/audit")).status).toBe(403); + const ownerConsole = (await call(owner, "GET", "/api/v1/console")).json; + expect(ownerConsole.sections).toEqual(["overview", "catalogs", "audit"]); + expect(ownerConsole.moderation).toBeNull(); + expect((await call(maker, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "audit"]); + expect((await call(admin, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "catalogs", "people", "audit", "settings"]); + + // The owner sees their catalog's entries, not the other catalog's; the maker sees their product's. + const ownerLog = (await call(owner, "GET", "/api/v1/console/audit")).json.entries; + expect(ownerLog.some((e: any) => e.action === "catalog.edit" && e.tags.catalog === c.id)).toBe(true); + expect(ownerLog.some((e: any) => e.tags.catalog === other.id)).toBe(false); + expect(ownerLog.find((e: any) => e.tags.catalog === c.id).tagNames[c.id]).toBe("Audit Shelf"); + const makerLog = (await call(maker, "GET", "/api/v1/console/audit")).json.entries; + expect(makerLog.map((e: any) => e.action)).toContain("product.edit"); + expect(makerLog.some((e: any) => e.tags.catalog === c.id && e.action === "catalog.edit")).toBe(false); + + // Admins see everything, and filter. + const filtered = (await call(admin, "GET", `/api/v1/console/audit?catalog=${other.id}&action=catalog.edit`)).json.entries; + expect(filtered).toHaveLength(1); + expect(filtered[0]).toMatchObject({ actor: "admin1", action: "catalog.edit" }); + expect((await call(admin, "GET", "/api/v1/console/audit?actor=maker6")).json.entries.every((e: any) => e.actor === "maker6")).toBe(true); + expect((await call(admin, "GET", "/api/v1/console/audit?q=legal@example")).json.entries).toHaveLength(1); +}); + +test("outfits relay only the hub products the wearer may wear; local packs pass", async () => { + const { Realtime } = await import("../src/realtime"); + const rt = new Realtime({ + owner: async () => "u1", + wearable: async (_, ids) => new Set(ids.filter((id) => id === "aaaaaaaaaa")), + }); + rt.open("s1", "u1", () => {}); + await rt.message("s1", { type: "session", imvu_id: 7 }); + await rt.message("s1", { + type: "outfit", + items: [ + { id: "aaaaaaaaaa", name: "Mine", adult: false }, + { id: "bbbbbbbbbb", name: "Members only", adult: false }, + { id: "local-pack-1", name: "A pack", adult: false }, + ], + }); + expect(rt.online()[0]!.outfit.map((i) => i.id)).toEqual(["aaaaaaaaaa", "local-pack-1"]); +}); diff --git a/apps/api/test/hub.test.ts b/apps/api/test/hub.test.ts index 8e85724..cee6013 100644 --- a/apps/api/test/hub.test.ts +++ b/apps/api/test/hub.test.ts @@ -229,8 +229,8 @@ describe("T5DE's proxy", () => { const admin = new Browser(); await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" }); const consoleView = (await admin.call("GET", "/api/v1/console")).json; - expect(consoleView.online[0]).toMatchObject({ username: "ErinAv", hidden: true }); - expect(consoleView.counts.computers).toBe(1); + expect(consoleView.moderation.online[0]).toMatchObject({ username: "ErinAv", hidden: true }); + expect(consoleView.moderation.counts.computers).toBe(1); 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([ diff --git a/apps/api/test/registration.test.ts b/apps/api/test/registration.test.ts index f55a3b8..3af832f 100644 --- a/apps/api/test/registration.test.ts +++ b/apps/api/test/registration.test.ts @@ -63,7 +63,7 @@ test("closed: new accounts wait for approval; the first one runs the hub", async expect((await call(bo, "GET", "/api/v1/console")).status).toBe(403); // Moderators see who's waiting, and approve them. - expect((await call(admin, "GET", "/api/v1/console")).json.counts.waiting).toBe(1); + expect((await call(admin, "GET", "/api/v1/console")).json.moderation.counts.waiting).toBe(1); const waiting = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people; expect(waiting.map((p: any) => p.username)).toEqual(["bo_"]); expect((await call(admin, "POST", `/api/v1/console/people/${waiting[0].id}/approve`)).json.person.approved).toBe(true); diff --git a/apps/web/app/catalogs/[id]/page.tsx b/apps/web/app/catalogs/[id]/page.tsx new file mode 100644 index 0000000..a2e6cf9 --- /dev/null +++ b/apps/web/app/catalogs/[id]/page.tsx @@ -0,0 +1,100 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { membership as membershipLabel } from "@/components/catalogs"; +import { ActionForm, SubmitButton } from "@/components/forms"; +import { PageHeader, Tag } from "@/components/status"; +import { buttonVariants } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { api, getMe, unwrap } from "@/lib/api"; +import { membership } from "../../console/catalogs/actions"; + +export const metadata: Metadata = { title: "Catalog" }; + +export default async function CatalogPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }>; + searchParams: Promise<{ invite?: string | string[] }>; +}) { + const { id } = await params; + const raw = (await searchParams).invite; + const invite = typeof raw === "string" && /^[a-z0-9]{1,32}$/.test(raw) ? raw : ""; + const [me, answer] = await Promise.all([getMe(), api.catalogs({ id }).get()]); + if (answer.error) notFound(); + const data = unwrap(answer); + const c = data.catalog; + const manage = data.permissions.includes("audit:read"); + + return ( +
+ + + {membershipLabel[c.membership] ?? c.membership} + {c.maxRating === "general" && General audiences} + {data.membership === "member" && Member} + {data.membership === "requested" && Asked to join} + {data.membership === "banned" && Banned} + + + +
+ + + Products + {data.hosted.products} + + + + + Takedown Contact + {c.contact} + + +
+ + + + Membership + + {c.membership === "open" + ? "Anyone on T5DE can get its products." + : c.membership === "approval" + ? "Ask to join; its team decides. Anyone can see its products on others." + : "Join with an invitation. Anyone can see its products on others."} + + + + {!me ? ( + + Sign in to join + + ) : data.membership === "banned" ? ( +

This catalog's team banned you from it.

+ ) : data.membership === "member" || data.membership === "requested" ? ( + + + {data.membership === "member" ? "Leave" : "Withdraw request"} + + + ) : c.membership === "open" ? ( +

Nothing to join: get its products from their pages.

+ ) : c.membership === "invite" && !invite ? ( +

You need an invitation link from its team.

+ ) : ( + + {invite ? "Accept invitation" : "Ask to join"} + + )} +
+
+ + {manage && ( + + Manage + + )} +
+ ); +} diff --git a/apps/web/app/catalogs/page.tsx b/apps/web/app/catalogs/page.tsx index 7429909..b98913a 100644 --- a/apps/web/app/catalogs/page.tsx +++ b/apps/web/app/catalogs/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import Link from "next/link"; import { CatalogStatus, membership } from "@/components/catalogs"; import { PageHeader } from "@/components/status"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -71,7 +72,11 @@ function CatalogTable({ catalogs, showStatus = false }: { catalogs: Catalog[]; s {catalogs.map((c) => ( - {c.name} + + + {c.name} + + {c.url} {membership[c.membership] ?? c.membership} {showStatus && {c.visibility}} diff --git a/apps/web/app/console/audit-table.tsx b/apps/web/app/console/audit-table.tsx new file mode 100644 index 0000000..e970b79 --- /dev/null +++ b/apps/web/app/console/audit-table.tsx @@ -0,0 +1,80 @@ +import Link from "next/link"; +import { DateTime } from "@/components/time"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import type { AuditEntry } from "@/lib/api"; + +const tagLinks: Record string> = { + catalog: (id) => `/console/catalogs/${id}`, + product: (id) => `/products/${id}`, + room: (id) => `/rooms/${id}/edit`, +}; + +/** Audit entries: when, who, what, which resources (linked), and the details. */ +export function AuditTable({ entries, empty = "Nothing yet." }: { entries: AuditEntry[]; empty?: string }) { + if (entries.length === 0) return

{empty}

; + return ( + + + + When + Who + What + About + Detail + + + + {entries.map((e) => ( + + + + + + {e.actor ? ( + + {e.actor} + + ) : ( + hub + )} + + {e.action} + + {Object.entries(e.tags as Record).map(([kind, id]) => { + if (!id) return null; + const name = (e.tagNames as Record)[id] ?? id; + const href = tagLinks[kind]?.(id); + return ( + + {kind} + {href ? ( + + {name} + + ) : ( + name + )} + + ); + })} + {Object.keys(e.tags).length === 0 && e.target} + + {detailText(e.detail)} + + ))} + +
+ ); +} + +function detailText(detail: unknown): string { + return Object.entries((detail ?? {}) as Record) + .map(([k, v]) => { + if (k === "changed" && v && typeof v === "object") + return Object.entries(v as Record) + .map(([f, c]) => `${f}: ${String(c?.from)} → ${String(c?.to)}`) + .join(", "); + return `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`; + }) + .join(", "); +} diff --git a/apps/web/app/console/audit/page.tsx b/apps/web/app/console/audit/page.tsx new file mode 100644 index 0000000..84d99e0 --- /dev/null +++ b/apps/web/app/console/audit/page.tsx @@ -0,0 +1,67 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { PageHeader } from "@/components/status"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { api, unwrap } from "@/lib/api"; +import { AuditTable } from "../audit-table"; +import { ConsoleNav, requireConsole } from "../console-nav"; + +export const metadata: Metadata = { title: "Audit Log" }; + +const filters = ["action", "actor", "person", "catalog", "product", "room", "q", "from", "to"] as const; +type Filters = Partial>; + +export default async function AuditPage({ searchParams }: { searchParams: Promise> }) { + const { console: data } = await requireConsole("/console/audit"); + const params = await searchParams; + const f: Filters = {}; + for (const k of filters) { + const v = params[k]; + if (typeof v === "string" && v.trim()) f[k] = v.trim().slice(0, 100); + } + const before = typeof params.before === "string" && /^\d+$/.test(params.before) ? Number(params.before) : undefined; + const { entries, before: next } = unwrap(await api.console.audit.get({ query: { ...f, ...(before ? { before } : {}), limit: 100 } })); + const older = new URLSearchParams({ ...f, ...(next ? { before: String(next) } : {}) }); + const moderator = data.moderation !== null; + + return ( + <> + + {moderator ? "Every change on the hub." : "Changes to what you manage, and the ones you made."} + + +
+ + + + {moderator && } + + + + +
+ + {Object.keys(f).length > 0 && ( + + Clear + + )} +
+
+ + + + + + {next && entries.length === 100 && ( +
+ + Older + +
+ )} + + ); +} diff --git a/apps/web/app/console/catalogs/[id]/page.tsx b/apps/web/app/console/catalogs/[id]/page.tsx new file mode 100644 index 0000000..411cf67 --- /dev/null +++ b/apps/web/app/console/catalogs/[id]/page.tsx @@ -0,0 +1,498 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { CatalogStatus } from "@/components/catalogs"; +import { ActionForm, SubmitButton } from "@/components/forms"; +import { formatSize, RatingTag } from "@/components/products"; +import { PageHeader, Tag } from "@/components/status"; +import { DateTime } from "@/components/time"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { api, unwrap, type CatalogMember, type CatalogPage } from "@/lib/api"; +import { AuditTable } from "../../audit-table"; +import { ConsoleNav, requireConsole } from "../../console-nav"; +import { + addOperator, + createInvite, + decideMember, + delist, + removeFromTeam, + revokeInvite, + rotateKey, + saveCatalog, + setSuspended, +} from "../actions"; + +export const metadata: Metadata = { title: "Catalog" }; + +type Perm = CatalogPage["permissions"][number]; + +export default async function ManageCatalog({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const { me } = await requireConsole(`/console/catalogs/${id}`); + const answer = await api.catalogs({ id }).get(); + if (answer.error) notFound(); + const data = unwrap(answer); + const has = (p: Perm) => data.permissions.includes(p); + if (!has("audit:read")) notFound(); + const c = data.catalog; + + const [team, members, invites, hosted, audit] = await Promise.all([ + api.catalogs({ id }).team.get().then(unwrap), + has("member:approve") ? api.catalogs({ id }).members.get().then(unwrap) : undefined, + has("member:invite") ? api.catalogs({ id }).invites.get().then(unwrap) : undefined, + api.catalogs({ id }).products.get().then(unwrap), + api.console.audit.get({ query: { catalog: id, limit: 20 } }).then(unwrap), + ]); + const byStatus = (s: CatalogMember["status"]) => members?.members.filter((m) => m.status === s) ?? []; + const onTeam = team.roles.some((r) => r.personId === me.id); + + return ( + <> + + + + {c.url} + {c.status === "approved" && ( + + Public page + + )} + + + +
+ {(c.status === "suspended" || c.status === "rejected") && ( + + {c.status === "suspended" ? "Suspended by moderation" : "Rejected"} + + {c.statusReason || "No reason given."}{" "} + {c.status === "suspended" && "Its products aren't available from it, and it can't take uploads."} + + + )} + +
+ + + {data.members && ( + <> + + + + )} +
+ + + + Settings + {!has("catalog:edit") && Only the owner and operators change these.} + + + +
+ + Name + + + + Address + + + + Takedown contact + + + + + + + +
+ {has("catalog:edit") && Save} +
+
+
+ + {has("catalog:rotate-key") && ( + + + Key + + The catalog signs in to the hub with its ID ({id}) and key. A new key stops the old one at once. + + + + + + New key + + + + + )} + + + + Team + The owner, and operators: they manage everything but the team itself. + + +
    + {team.owner && ( +
  • + {team.owner.username} + owner +
  • + )} + {team.roles.map((r) => ( +
  • + {r.username} + + operator + {(has("team:manage") || r.personId === me.id) && ( + + + {r.personId === me.id ? "Leave" : "Remove"} + + + )} + +
  • + ))} +
+ {has("team:manage") && ( + +
+ + Add operator +
+
+ )} + {!onTeam && team.owner?.personId !== me.id && ( +

You see this as a moderator.

+ )} +
+
+ + {members && ( + + + Members + + {c.membership === "open" + ? "Open: anyone can get its products. Bans still apply." + : c.membership === "approval" + ? "By approval: people ask, the team decides." + : "Invite only: people join with an invitation."} + + + + + + + +
+ + + Add member + +
+
+
+
+ )} + + {invites && ( + + + Invitations + Links that let people join (also an approval or invite-only catalog) at once. + + + {invites.invites.length > 0 && ( + + + + Link + Used + Expires + + + + + {invites.invites.map((i) => ( + + {i.url} + + {i.uses} + {i.maxUses !== null && ` of ${i.maxUses}`} + + + {i.expiresAt ? : "never"} + + + + + Revoke + + + + + ))} + +
+ )} + +
+ + Uses + + + + Days + + + New invitation +
+
+
+
+ )} + + + + Products + What it hosts. Delisting stops hosting a product here; other catalogs may still host it. + + + {hosted.products.length === 0 ? ( +

Nothing yet.

+ ) : ( + + + + Product + Creator + Revisions + Since + {has("hosting:delist") && } + + + + {hosted.products.map((p) => ( + + + + {p.name} + {" "} + {" "} + {p.removed && removed} + {p.deleted && deleted} + + {p.creator} + {p.revisions.join(", ")} + + + + {has("hosting:delist") && ( + + +
+ + + Delist + +
+
+
+ )} +
+ ))} +
+
+ )} +
+
+ + {has("catalog:suspend") && (c.status === "approved" || c.status === "suspended") && ( + + + Moderation + + A suspended catalog's products aren't available from it, it can't take uploads, and its team sees why. + + + + + {c.status === "suspended" ? ( + + Reinstate + + ) : ( +
+ + + Suspend + +
+ )} +
+
+
+ )} + + + + Recent Changes + + + All changes to this catalog + + + + + + + +
+ + ); +} + +function Stat({ label, value, note }: { label: string; value: string; note: string }) { + return ( + + + {label} + {value} + + {note} + + ); +} + +function Choice({ name, label, value, options }: { name: string; label: string; value: string; options: [string, string][] }) { + return ( + + {label} + + {options.map(([v, l]) => ( + + {l} + + ))} + + + ); +} + +const memberActions = { + approve: { label: "Approve", variant: "default" as const }, + decline: { label: "Decline", variant: "outline" as const }, + ban: { label: "Ban", variant: "outline" as const }, + remove: { label: "Remove", variant: "outline" as const }, + unban: { label: "Unban", variant: "outline" as const }, +}; + +function MemberList({ + id, + title, + members, + actions, +}: { + id: string; + title: string; + members: CatalogMember[]; + actions: (keyof typeof memberActions)[]; +}) { + if (members.length === 0) return null; + return ( +
+

+ {title} {members.length} +

+
    + {members.map((m) => ( +
  • + + {m.username}{" "} + + {m.invited ? "invited, " : ""} + + + + +
    + {actions.map((a) => ( + + {memberActions[a].label} + + ))} +
    +
    +
  • + ))} +
+
+ ); +} diff --git a/apps/web/app/console/catalogs/actions.ts b/apps/web/app/console/catalogs/actions.ts new file mode 100644 index 0000000..c980ef5 --- /dev/null +++ b/apps/web/app/console/catalogs/actions.ts @@ -0,0 +1,122 @@ +"use server"; +// Managing a catalog from the console: settings, key, team, members, invitations, what it hosts, suspension. +import { revalidatePath } from "next/cache"; +import { api, unwrap } from "@/lib/api"; +import { attempt, field, type ActionState } from "@/lib/actions"; + +/** Runs a change to a catalog, then shows its pages again. */ +async function onCatalog(form: FormData, change: (id: string) => Promise): Promise { + const id = field(form, "id"); + return attempt(async () => { + const notice = await change(id); + revalidatePath(`/console/catalogs/${id}`); + revalidatePath(`/catalogs/${id}`); + revalidatePath("/console/catalogs"); + revalidatePath("/catalogs"); + return notice; + }); +} + +const pick = (v: string, options: readonly T[]): T | undefined => options.find((o) => o === v); + +export async function saveCatalog(_: ActionState, form: FormData): Promise { + return onCatalog(form, async (id) => { + unwrap( + await api.catalogs({ id }).patch({ + name: field(form, "name"), + url: field(form, "url"), + contact: field(form, "contact"), + visibility: pick(field(form, "visibility"), ["public", "private"] as const), + membership: pick(field(form, "membership"), ["open", "approval", "invite"] as const), + maxRating: pick(field(form, "maxRating"), ["general", "adult"] as const), + uploads: pick(field(form, "uploads"), ["anyone", "members", "team"] as const), + mirrors: pick(field(form, "mirrors"), ["auto", "approve", "off"] as const), + }), + ); + return "Saved."; + }); +} + +export async function rotateKey(_: ActionState, form: FormData): Promise { + return onCatalog(form, async (id) => { + const { key } = unwrap(await api.catalogs({ id }).key.post()); + return `New key (shown once; put it in the catalog's CATALOG_KEY and restart it): ${key}`; + }); +} + +export async function setSuspended(_: ActionState, form: FormData): Promise { + return onCatalog(form, async (id) => { + if (field(form, "intent") === "reinstate") unwrap(await api.catalogs({ id }).reinstate.post()); + else unwrap(await api.catalogs({ id }).suspend.post({ reason: field(form, "reason") })); + }); +} + +export async function addOperator(_: ActionState, form: FormData): Promise { + return onCatalog(form, async (id) => { + const username = field(form, "username"); + unwrap(await api.catalogs({ id }).team({ username }).put({ role: "catalog-operator" })); + return `${username} is an operator now.`; + }); +} + +export async function removeFromTeam(_: ActionState, form: FormData): Promise { + return onCatalog(form, async (id) => { + unwrap(await api.catalogs({ id }).team({ username: field(form, "username") }).delete({}, { query: { role: "catalog-operator" } })); + }); +} + +/** A member or request: approve, decline, add, ban, unban or remove. */ +export async function decideMember(_: ActionState, form: FormData): Promise { + return onCatalog(form, async (id) => { + const member = api.catalogs({ id }).members({ username: field(form, "username") }); + switch (field(form, "decision")) { + case "approve": + case "add": + unwrap(await member.put({ status: "member" })); + break; + case "ban": + unwrap(await member.put({ status: "banned" })); + break; + default: // decline, unban, remove + unwrap(await member.delete()); + } + }); +} + +export async function createInvite(_: ActionState, form: FormData): Promise { + return onCatalog(form, async (id) => { + const maxUses = Number(field(form, "maxUses")) || undefined; + const expiresInDays = Number(field(form, "expiresInDays")) || undefined; + const { invite } = unwrap(await api.catalogs({ id }).invites.post({ maxUses, expiresInDays })); + return `Invitation link: ${invite.url}`; + }); +} + +export async function revokeInvite(_: ActionState, form: FormData): Promise { + return onCatalog(form, async (id) => { + unwrap(await api.catalogs({ id }).invites({ code: field(form, "code") }).delete()); + }); +} + +export async function delist(_: ActionState, form: FormData): Promise { + return onCatalog(form, async (id) => { + const product = field(form, "product"); + unwrap(await api.catalogs({ id }).products({ product }).delete({}, { query: { reason: field(form, "reason") } })); + revalidatePath(`/products/${product}`); + }); +} + +/** Someone joins a catalog (or asks to), with an invitation code if they have one; or leaves it. */ +export async function membership(_: ActionState, form: FormData): Promise { + return onCatalog(form, async (id) => { + if (field(form, "intent") === "leave") { + unwrap(await api.catalogs({ id }).join.delete()); + revalidatePath("/library"); + return "You left."; + } + const invite = field(form, "invite"); + const { membership } = unwrap(await api.catalogs({ id }).join.post(invite ? { invite } : {})); + revalidatePath("/library"); + return membership === "member" ? "You're a member." : "Asked. The catalog's team will decide."; + }); +} diff --git a/apps/web/app/console/catalogs/page.tsx b/apps/web/app/console/catalogs/page.tsx index 61648ee..41dd962 100644 --- a/apps/web/app/console/catalogs/page.tsx +++ b/apps/web/app/console/catalogs/page.tsx @@ -1,23 +1,24 @@ import type { Metadata } from "next"; +import Link from "next/link"; import { CatalogStatus, membership } from "@/components/catalogs"; import { ActionForm, SubmitButton } from "@/components/forms"; -import { PageHeader } from "@/components/status"; +import { PageHeader, Tag } from "@/components/status"; import { DateTime } from "@/components/time"; import { Card, CardContent } from "@/components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { api, unwrap } from "@/lib/api"; import { decideCatalog } from "../actions"; -import { ConsoleNav, requireModerator } from "../console-nav"; +import { ConsoleNav, requireConsole } from "../console-nav"; export const metadata: Metadata = { title: "Catalogs" }; export default async function ConsoleCatalogs() { - await requireModerator("/console/catalogs"); - const { catalogs } = unwrap(await api.console.catalogs.get()); + await requireConsole("/console/catalogs"); + const { catalogs, moderator } = unwrap(await api.console.catalogs.get()); return ( <> - + {moderator ? "Every registered catalog." : "Catalogs you're on the team of."} @@ -29,19 +30,22 @@ export default async function ConsoleCatalogs() { Name Address - Takedown contact Access Registered Status - + {moderator && } {catalogs.map((c) => ( - {c.name} + + + {c.name} + {" "} + {c.mine ? owner : c.team ? team : null} + {c.url} - {c.contact} {c.visibility}, {membership[c.membership]?.toLowerCase() ?? c.membership} @@ -51,29 +55,31 @@ export default async function ConsoleCatalogs() { - - -
- {c.status !== "approved" && ( - - Approve - - )} - {c.status !== "rejected" && ( - - Reject - - )} -
-
-
+ {moderator && ( + + {(c.status === "pending" || c.status === "rejected") && ( + +
+ + Approve + + {c.status !== "rejected" && ( + + Reject + + )} +
+
+ )} +
+ )}
))}
diff --git a/apps/web/app/console/console-nav.tsx b/apps/web/app/console/console-nav.tsx index 6c7fd78..900c44f 100644 --- a/apps/web/app/console/console-nav.tsx +++ b/apps/web/app/console/console-nav.tsx @@ -1,8 +1,9 @@ import { notFound, redirect } from "next/navigation"; +import { cache } from "react"; import { NavLink } from "@/components/header-client"; -import { getMe, isModerator, type Person } from "@/lib/api"; +import { api, getMe, isModerator, unwrap, type ConsoleData, type Person } from "@/lib/api"; -/** The signed-in moderator, or off to sign in; others get not found (the console isn't for them). */ +/** The signed-in moderator, or off to sign in; others get not found (these pages aren't for them). */ export async function requireModerator(path: string): Promise { const me = await getMe(); if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`); @@ -10,14 +11,47 @@ export async function requireModerator(path: string): Promise { return me; } -export function ConsoleNav() { +/** The console as this person sees it (once per request); undefined if it isn't for them. */ +const loadConsole = cache(async (): Promise => { + const answer = await api.console.get(); + if (answer.error) return undefined; + return unwrap(answer); +}); + +/** + * The signed-in person and their console, or off to sign in. People who manage nothing get not found: the console + * is for those controlling a product, catalog or room. + */ +export async function requireConsole(path: string): Promise<{ me: Person; console: ConsoleData }> { + const me = await getMe(); + if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`); + const data = await loadConsole(); + if (!data) notFound(); + return { me, console: data }; +} + +type Section = ConsoleData["sections"][number]; + +// Products are managed on their own pages for now; settings are on the overview. +const tabs: { section: Section; href: string; label: string }[] = [ + { section: "overview", href: "/console", label: "Overview" }, + { section: "catalogs", href: "/console/catalogs", label: "Catalogs" }, + { section: "people", href: "/console/people", label: "People" }, + { section: "audit", href: "/console/audit", label: "Audit" }, +]; + +/** The console's sections this person may see. */ +export async function ConsoleNav() { + const sections = new Set
((await loadConsole())?.sections ?? ["overview"]); return ( ); } diff --git a/apps/web/app/console/page.tsx b/apps/web/app/console/page.tsx index 80e4fec..322b329 100644 --- a/apps/web/app/console/page.tsx +++ b/apps/web/app/console/page.tsx @@ -3,61 +3,77 @@ import Link from "next/link"; import { ActionForm, SubmitButton } from "@/components/forms"; import { OnlineList } from "@/components/online-list"; import { PageHeader } from "@/components/status"; -import { DateTime } from "@/components/time"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { api, unwrap, type AuditEntry } from "@/lib/api"; +import { api, unwrap } from "@/lib/api"; import { setRegistration } from "./actions"; -import { ConsoleNav, requireModerator } from "./console-nav"; +import { AuditTable } from "./audit-table"; +import { ConsoleNav, requireConsole } from "./console-nav"; export const metadata: Metadata = { title: "Console" }; +type Stat = { label: string; value: number; note: string; href?: string }; + export default async function Console() { - const me = await requireModerator("/console"); - const [data, settings] = await Promise.all([api.console.get().then(unwrap), api.console.settings.get().then(unwrap)]); - const stats = [ - { label: "People", value: data.counts.people, note: "accounts" }, - { label: "Waiting", value: data.counts.waiting, note: "for approval", href: "/console/people?waiting=true" }, - { label: "IMVU accounts", value: data.counts.links, note: "linked" }, - { label: "Computers", value: data.counts.computers, note: "signed in" }, + const { me, console: data } = await requireConsole("/console"); + const settings = me.role === "admin" ? await api.console.settings.get().then(unwrap) : undefined; + + const todo: Stat[] = [ + { label: "Member Requests", value: data.todo.memberRequests, note: "waiting in your catalogs", href: "/console/catalogs" }, ]; + if (data.moderation) { + todo.push( + { label: "Catalogs", value: data.todo.pendingCatalogs, note: "waiting for approval", href: "/console/catalogs" }, + { label: "Accounts", value: data.todo.waitingPeople, note: "waiting for approval", href: "/console/people?waiting=true" }, + ); + } + const mine: Stat[] = [ + { label: "Your Products", value: data.mine.products, note: "you created", href: `/products?creator=${encodeURIComponent(me.username)}` }, + { label: "Your Catalogs", value: data.mine.catalogs, note: "you're on the team of", href: "/console/catalogs" }, + { label: "Your Rooms", value: data.mine.rooms, note: "you host", href: "/rooms" }, + ]; + const hub: Stat[] = data.moderation + ? [ + { label: "People", value: data.moderation.counts.people, note: "accounts" }, + { label: "IMVU Accounts", value: data.moderation.counts.links, note: "linked" }, + { label: "Computers", value: data.moderation.counts.computers, note: "signed in" }, + ] + : []; return ( <> - Moderation. + What you manage on T5DE.
-
- {stats.map((s) => ( - - - {s.label} - {s.value} - - - {s.href ? ( - - {s.note} - - ) : ( - s.note - )} - - - ))} -
+ s.value > 0 || data.moderation)} /> + s.value > 0)} /> + {hub.length > 0 && } - - - Registration - - {settings.registration === "closed" - ? "Closed: new accounts can sign in, but wait for a moderator's approval." - : "Open: new accounts can use the hub at once."} - - - {me.role === "admin" && ( + {data.moderation && ( + + + Online Now + + {data.moderation.connections} {data.moderation.connections === 1 ? "connection" : "connections"} when + the page loaded + + + + + + + )} + + {settings && ( + + + Registration + + {settings.registration === "closed" + ? "Closed: new accounts can sign in, but wait for a moderator's approval." + : "Open: new accounts can use the hub at once."} + +
@@ -69,28 +85,21 @@ export default async function Console() {
- )} -
+
+ )} - Online now + Recent Changes - {data.connections} {data.connections === 1 ? "connection" : "connections"} when the page loaded + {data.moderation ? "Across the hub." : "To what you manage, and by you."}{" "} + + Full audit log + - - - - - - - Audit log - The latest 50 changes. - - - +
@@ -98,36 +107,27 @@ export default async function Console() { ); } -function AuditLog({ entries }: { entries: AuditEntry[] }) { - if (entries.length === 0) return

Nothing yet.

; +function StatGrid({ stats }: { stats: Stat[] }) { + if (stats.length === 0) return null; return ( - - - - When - Who - What - Target - Detail - - - - {entries.map((e, i) => ( - - - - - {e.actor || hub} - {e.action} - {e.target} - - {Object.entries((e.detail ?? {}) as Record) - .map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`) - .join(", ")} - - - ))} - -
+
+ {stats.map((s) => ( + + + {s.label} + {s.value} + + + {s.href ? ( + + {s.note} + + ) : ( + s.note + )} + + + ))} +
); } diff --git a/apps/web/app/products/[id]/page.tsx b/apps/web/app/products/[id]/page.tsx index a9d9ded..da2d259 100644 --- a/apps/web/app/products/[id]/page.tsx +++ b/apps/web/app/products/[id]/page.tsx @@ -19,7 +19,7 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const [me, data] = await Promise.all([getMe(), loadProduct(id)]); - const { product, revisions, inLibrary, stayOn, mine } = data; + const { product, revisions, inLibrary, stayOn, mine, available, catalogs } = data; const using = inLibrary ? inUse(revisions, stayOn ?? product.latest) : undefined; return ( @@ -62,10 +62,24 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st Remove - ) : ( + ) : available ? ( Get + ) : ( + + Members only. Join{" "} + {catalogs.map((c, i) => ( + + {i > 0 && (i === catalogs.length - 1 ? " or " : ", ")} + + {c.name} + + {c.joined === "requested" && " (asked)"} + + ))}{" "} + to get it. + )} diff --git a/apps/web/app/products/page.tsx b/apps/web/app/products/page.tsx index 0a9baa9..27c0669 100644 --- a/apps/web/app/products/page.tsx +++ b/apps/web/app/products/page.tsx @@ -8,15 +8,24 @@ import { api, getMe, unwrap } from "@/lib/api"; export const metadata: Metadata = { title: "Products" }; -export default async function Products({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) { - const raw = (await searchParams).q; - const q = typeof raw === "string" ? raw.trim().slice(0, 100) : ""; - const [me, { products }] = await Promise.all([getMe(), api.products.get({ query: q ? { q } : {} }).then(unwrap)]); +export default async function Products({ + searchParams, +}: { + searchParams: Promise<{ q?: string | string[]; creator?: string | string[] }>; +}) { + const params = await searchParams; + const one = (v: string | string[] | undefined, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : ""); + const q = one(params.q, 100); + const creator = one(params.creator, 64); + const [me, { products }] = await Promise.all([ + getMe(), + api.products.get({ query: { ...(q ? { q } : {}), ...(creator ? { creator } : {}) } }).then(unwrap), + ]); return ( <>
- Community products for T5DE. + {creator ? `By ${creator}.` : "Community products for T5DE."} {me && ( New product @@ -25,6 +34,7 @@ export default async function Products({ searchParams }: { searchParams: Promise
+ {creator && } diff --git a/apps/web/components/catalogs.tsx b/apps/web/components/catalogs.tsx index e5fcf5b..7d11679 100644 --- a/apps/web/components/catalogs.tsx +++ b/apps/web/components/catalogs.tsx @@ -2,9 +2,9 @@ import { Tag } from "@/components/status"; export const membership: Record = { open: "Open", approval: "By approval", invite: "Invite only" }; -/** A catalog's review status: approved, pending or rejected. */ +/** A catalog's status: approved, pending, rejected or suspended. */ export function CatalogStatus({ status }: { status: string }) { if (status === "approved") return approved; - if (status === "rejected") return rejected; + if (status === "rejected" || status === "suspended") return {status}; return {status}; } diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx index 53d5bcb..cce271e 100644 --- a/apps/web/components/header.tsx +++ b/apps/web/components/header.tsx @@ -1,6 +1,6 @@ import Image from "next/image"; import Link from "next/link"; -import { getMe, isModerator } from "@/lib/api"; +import { getMe } from "@/lib/api"; import { buttonVariants } from "@/components/ui/button"; import { NavLink, ProfileMenu } from "./header-client"; import logo from "./t5de.png"; @@ -28,7 +28,7 @@ export async function Header() { Download {me ? ( - + ) : ( <> diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index bef0177..0d7f5bc 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -41,6 +41,11 @@ export type AgeInfo = Data; export type OnlineEntry = Data["online"][number]; export type ConsoleData = Data; export type AuditEntry = ConsoleData["audit"][number]; +export type CatalogPage = Data["get"]>; +export type CatalogMember = Data["members"]["get"]>["members"][number]; +export type CatalogInvite = Data["invites"]["get"]>["invites"][number]; +export type HostedProduct = Data["products"]["get"]>["products"][number]; +export type CatalogTeam = Data["team"]["get"]>; export type ProductSummary = Data["products"][number]; export type ProductPage = Data["get"]>; export type Revision = ProductPage["revisions"][number]; diff --git a/docs/api.md b/docs/api.md index 03b5647..f7ac39c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -20,7 +20,7 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco | `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. With registration closed, the others wait for approval (`approved: false`): every route but `/me` answers 403 `awaiting_approval`, and the rest of the hub treats them as signed out. | | `POST /api/auth/sign-in/username` | `{username, password}` | | `POST /api/auth/sign-out` | | -| `GET /api/v1/me` | `{person}` (`approved` false while waiting), with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null` | +| `GET /api/v1/me` | `{person}` (`approved` false while waiting), with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null`, and `console`: whether the console is for them | | `GET /api/v1/hub` | (anyone) `{registration}`: `open` or `closed` | ## Proxies @@ -103,11 +103,22 @@ person-level providers (phone wallets, document checks) are started here. | `POST /api/v1/age/:provider/complete` | `{ref, response}`: the browser's answer, for wallet providers | | `POST /api/v1/age/webhook/:provider` | the provider's webhook; the provider checks its signature (401 if it isn't the provider's). A later answer replaces an earlier one (an adult answer taken back is `revoked`) | -## Console (moderators; roles: admins) +## Console + +Permissions, roles and scopes are in docs/console.md. A missing permission answers 403 `forbidden` with +`missing: {permission, scope}`. Catalog management routes are in docs/products.md. + +| | | +| --- | --- | +| `GET /api/v1/console` | (anyone controlling a catalog, product or hosted room, and moderators; else 403) `{sections, mine, todo, moderation, audit}`: `moderation` (`{counts, connections, online}`) only for moderators, `audit` the latest 20 entries they may see | +| `GET /api/v1/console/audit?catalog=&product=&room=&person=&actor=&action=&q=&from=&to=&before=&limit=` | `{entries, before}`: entries the person may see (docs/console.md), newest first; `action` matches a prefix, `from`/`to` are days, `before` an entry ID for the next page. Entries are `{id, at, actor, action, target, tags, tagNames, detail}` | +| `GET /api/v1/console/permissions?catalog=&product=` | `{roles, permissions}` at that scope (the hub if neither) | +| `GET /api/v1/console/catalogs` | `{catalogs, moderator}`: every catalog for moderators, else those on whose team the person is | + +Moderators: | | | | --- | --- | -| `GET /api/v1/console` | `{counts, connections, online, audit}` | | `GET /api/v1/console/people?q=&waiting=true` | `{people}` (`waiting`: only accounts waiting for approval) | | `POST /api/v1/console/people/:id/approve` | `{person}` | | `POST /api/v1/console/people/:id/decline` | an account waiting for approval: deletes it | diff --git a/docs/design.md b/docs/design.md index d03eb0d..0ae4ebc 100644 --- a/docs/design.md +++ b/docs/design.md @@ -470,6 +470,8 @@ Phases for products, catalogs and imports (products.md, console.md, imports.md): 1. Permissions and console foundations: the permission check, roles, the console for everyone who controls something, a tagged audit log, membership enforced, catalog settings, team and key rotation, suspension. + **Done** (2026-11-05), except catalog deletion requests and the hosted rooms' scope, which come with cases + (phase 3). Built-in roles are code (`apps/api/src/permissions.ts`); the `role` table comes with custom roles. 2. Product info: drafts, moderator edits with reasons and locks, info history, unlisted products, tags and category. 3. Cases (reports, appeals, reviews), file blocks and the catalogs' changes feed; notifications; API tokens; two-factor sign-in for sensitive permissions. diff --git a/packages/db/drizzle/0010_catalog_management.sql b/packages/db/drizzle/0010_catalog_management.sql new file mode 100644 index 0000000..40b8abd --- /dev/null +++ b/packages/db/drizzle/0010_catalog_management.sql @@ -0,0 +1,62 @@ +CREATE TABLE "catalog_change" ( + "id" serial PRIMARY KEY NOT NULL, + "catalog_id" text NOT NULL, + "data" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "catalog_invite" ( + "code" text PRIMARY KEY NOT NULL, + "catalog_id" text NOT NULL, + "created_by" text, + "max_uses" integer, + "uses" integer DEFAULT 0 NOT NULL, + "expires_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "catalog_member" ( + "catalog_id" text NOT NULL, + "user_id" text NOT NULL, + "status" text NOT NULL, + "invite" text, + "decided_by" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "catalog_member_catalog_id_user_id_pk" PRIMARY KEY("catalog_id","user_id") +); +--> statement-breakpoint +CREATE TABLE "role_assignment" ( + "user_id" text NOT NULL, + "role" text NOT NULL, + "scope_type" text NOT NULL, + "scope_id" text NOT NULL, + "granted_by" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "role_assignment_user_id_role_scope_type_scope_id_pk" PRIMARY KEY("user_id","role","scope_type","scope_id") +); +--> statement-breakpoint +ALTER TABLE "audit" ADD COLUMN "tags" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint +ALTER TABLE "catalog" ADD COLUMN "status_reason" text DEFAULT '' NOT NULL;--> statement-breakpoint +ALTER TABLE "catalog" ADD COLUMN "max_rating" text DEFAULT 'adult' NOT NULL;--> statement-breakpoint +ALTER TABLE "catalog" ADD COLUMN "uploads" text DEFAULT 'anyone' NOT NULL;--> statement-breakpoint +ALTER TABLE "catalog" ADD COLUMN "mirrors" text DEFAULT 'approve' NOT NULL;--> statement-breakpoint +ALTER TABLE "catalog" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint +ALTER TABLE "catalog_change" ADD CONSTRAINT "catalog_change_catalog_id_catalog_id_fk" FOREIGN KEY ("catalog_id") REFERENCES "public"."catalog"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "catalog_invite" ADD CONSTRAINT "catalog_invite_catalog_id_catalog_id_fk" FOREIGN KEY ("catalog_id") REFERENCES "public"."catalog"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "catalog_invite" ADD CONSTRAINT "catalog_invite_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "catalog_member" ADD CONSTRAINT "catalog_member_catalog_id_catalog_id_fk" FOREIGN KEY ("catalog_id") REFERENCES "public"."catalog"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "catalog_member" ADD CONSTRAINT "catalog_member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "catalog_member" ADD CONSTRAINT "catalog_member_decided_by_user_id_fk" FOREIGN KEY ("decided_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "role_assignment" ADD CONSTRAINT "role_assignment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "role_assignment" ADD CONSTRAINT "role_assignment_granted_by_user_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "catalog_change_catalog" ON "catalog_change" USING btree ("catalog_id","id");--> statement-breakpoint +CREATE INDEX "catalog_invite_catalog" ON "catalog_invite" USING btree ("catalog_id");--> statement-breakpoint +CREATE INDEX "catalog_member_user" ON "catalog_member" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "role_assignment_scope" ON "role_assignment" USING btree ("scope_type","scope_id");--> statement-breakpoint +CREATE INDEX "audit_tags" ON "audit" USING gin ("tags");--> statement-breakpoint +-- Tags for entries written before tags existed (target was the catalog, product or room). +UPDATE "audit" SET "tags" = jsonb_build_object('catalog', "target") WHERE "action" LIKE 'catalog.%' AND "target" <> '';--> statement-breakpoint +UPDATE "audit" SET "tags" = jsonb_build_object('product', "target") WHERE "action" LIKE 'product.%' AND "target" <> '';--> statement-breakpoint +UPDATE "audit" SET "tags" = jsonb_build_object('room', "target") WHERE "action" LIKE 'hosted_room.%' AND "target" <> ''; diff --git a/packages/db/drizzle/meta/0010_snapshot.json b/packages/db/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..1969e6b --- /dev/null +++ b/packages/db/drizzle/meta/0010_snapshot.json @@ -0,0 +1,2768 @@ +{ + "id": "2a4756bc-d197-4321-84ef-a8238ed002d3", + "prevId": "03ea80f3-9f56-4e9a-8fa9-7faec3d8c472", + "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" + }, + "tags": { + "name": "tags", + "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": {} + }, + "audit_tags": { + "name": "audit_tags", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "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'" + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "max_rating": { + "name": "max_rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'adult'" + }, + "uploads": { + "name": "uploads", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'anyone'" + }, + "mirrors": { + "name": "mirrors", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'approve'" + }, + "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()" + }, + "updated_at": { + "name": "updated_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.catalog_change": { + "name": "catalog_change", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_change_catalog": { + "name": "catalog_change_catalog", + "columns": [ + { + "expression": "catalog_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_change_catalog_id_catalog_id_fk": { + "name": "catalog_change_catalog_id_catalog_id_fk", + "tableFrom": "catalog_change", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_invite": { + "name": "catalog_invite", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uses": { + "name": "uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_invite_catalog": { + "name": "catalog_invite_catalog", + "columns": [ + { + "expression": "catalog_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_invite_catalog_id_catalog_id_fk": { + "name": "catalog_invite_catalog_id_catalog_id_fk", + "tableFrom": "catalog_invite", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "catalog_invite_created_by_user_id_fk": { + "name": "catalog_invite_created_by_user_id_fk", + "tableFrom": "catalog_invite", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_member": { + "name": "catalog_member", + "schema": "", + "columns": { + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invite": { + "name": "invite", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by": { + "name": "decided_by", + "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": { + "catalog_member_user": { + "name": "catalog_member_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_member_catalog_id_catalog_id_fk": { + "name": "catalog_member_catalog_id_catalog_id_fk", + "tableFrom": "catalog_member", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "catalog_member_user_id_user_id_fk": { + "name": "catalog_member_user_id_user_id_fk", + "tableFrom": "catalog_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "catalog_member_decided_by_user_id_fk": { + "name": "catalog_member_decided_by_user_id_fk", + "tableFrom": "catalog_member", + "tableTo": "user", + "columnsFrom": [ + "decided_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "catalog_member_catalog_id_user_id_pk": { + "name": "catalog_member_catalog_id_user_id_pk", + "columns": [ + "catalog_id", + "user_id" + ] + } + }, + "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 + }, + "privacy": { + "name": "privacy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "moderators": { + "name": "moderators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "image_hash": { + "name": "image_hash", + "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": { + "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.hosted_room_ban": { + "name": "hosted_room_ban", + "schema": "", + "columns": { + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "by_imvu_id": { + "name": "by_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "until": { + "name": "until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hosted_room_ban_n_hosted_room_n_fk": { + "name": "hosted_room_ban_n_hosted_room_n_fk", + "tableFrom": "hosted_room_ban", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_ban_n_imvu_id_pk": { + "name": "hosted_room_ban_n_imvu_id_pk", + "columns": [ + "n", + "imvu_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_favorite": { + "name": "hosted_room_favorite", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hosted_room_favorite_user_id_user_id_fk": { + "name": "hosted_room_favorite_user_id_user_id_fk", + "tableFrom": "hosted_room_favorite", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hosted_room_favorite_n_hosted_room_n_fk": { + "name": "hosted_room_favorite_n_hosted_room_n_fk", + "tableFrom": "hosted_room_favorite", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_favorite_user_id_n_pk": { + "name": "hosted_room_favorite_user_id_n_pk", + "columns": [ + "user_id", + "n" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_image": { + "name": "hosted_room_image", + "schema": "", + "columns": { + "n": { + "name": "n", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hosted_room_image_n_hosted_room_n_fk": { + "name": "hosted_room_image_n_hosted_room_n_fk", + "tableFrom": "hosted_room_image", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_rating": { + "name": "hosted_room_rating", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosted_room_rating_room": { + "name": "hosted_room_rating_room", + "columns": [ + { + "expression": "n", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosted_room_rating_user_id_user_id_fk": { + "name": "hosted_room_rating_user_id_user_id_fk", + "tableFrom": "hosted_room_rating", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hosted_room_rating_n_hosted_room_n_fk": { + "name": "hosted_room_rating_n_hosted_room_n_fk", + "tableFrom": "hosted_room_rating", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_rating_user_id_n_pk": { + "name": "hosted_room_rating_user_id_n_pk", + "columns": [ + "user_id", + "n" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_visit": { + "name": "hosted_room_visit", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosted_room_visit_at": { + "name": "hosted_room_visit_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosted_room_visit_user_id_user_id_fk": { + "name": "hosted_room_visit_user_id_user_id_fk", + "tableFrom": "hosted_room_visit", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hosted_room_visit_n_hosted_room_n_fk": { + "name": "hosted_room_visit_n_hosted_room_n_fk", + "tableFrom": "hosted_room_visit", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_visit_user_id_n_pk": { + "name": "hosted_room_visit_user_id_n_pk", + "columns": [ + "user_id", + "n" + ] + } + }, + "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.hub_setting": { + "name": "hub_setting", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "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.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 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_reason": { + "name": "removed_reason", + "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": { + "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.role_assignment": { + "name": "role_assignment", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "role_assignment_scope": { + "name": "role_assignment_scope", + "columns": [ + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_assignment_user_id_user_id_fk": { + "name": "role_assignment_user_id_user_id_fk", + "tableFrom": "role_assignment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_assignment_granted_by_user_id_fk": { + "name": "role_assignment_granted_by_user_id_fk", + "tableFrom": "role_assignment", + "tableTo": "user", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_assignment_user_id_role_scope_type_scope_id_pk": { + "name": "role_assignment_user_id_role_scope_type_scope_id_pk", + "columns": [ + "user_id", + "role", + "scope_type", + "scope_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.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 + }, + "approved": { + "name": "approved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "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 93e0f41..cec4d21 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1793707706030, "tag": "0009_registration", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1793862017365, + "tag": "0010_catalog_management", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index b60e38c..2cd849a 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 { DecorationItem, FileEntry, ParentInfo, RoomModerator, SceneItem } from "./schema"; +export type { AuditTags, DecorationItem, FileEntry, ParentInfo, RoomModerator, 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 564f808..2479562 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -221,10 +221,15 @@ export const audit = pgTable( action: text("action").notNull(), target: text("target").notNull().default(""), detail: jsonb("detail").$type>().notNull().default({}), + // The resources the change touches ({catalog, product, room, person}): who may see the entry (docs/console.md). + tags: jsonb("tags").$type().notNull().default({}), }, - (t) => [index("audit_at").on(t.at)], + (t) => [index("audit_at").on(t.at), index("audit_tags").using("gin", t.tags)], ); +/** The resources an audit entry touches. */ +export type AuditTags = { catalog?: string; product?: string; room?: string; person?: string }; + // ---- Products (docs/products.md) ---- export type FileEntry = { name: string; sha256: string; size: number }; @@ -259,13 +264,89 @@ export const catalog = pgTable( 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" + status: text("status").notNull().default("pending"), // "pending", "approved", "rejected", "suspended" + statusReason: text("status_reason").notNull().default(""), // why it was rejected or suspended + maxRating: text("max_rating").notNull().default("adult"), // the highest rating it hosts: "general", "adult" + uploads: text("uploads").notNull().default("anyone"), // who may upload: "anyone", "members", "team" + mirrors: text("mirrors").notNull().default("approve"), // mirroring others' revisions: "auto", "approve", "off" keyHash: text("key_hash").notNull(), // sha256 of its key, hex createdAt: createdAt(), + updatedAt: updatedAt(), }, (t) => [uniqueIndex("catalog_url").on(t.url)], ); +// A role given to a person at a scope (docs/console.md). Hub-wide roles are user.role; owners are derived (a +// catalog's owner_id, a product's creator_id), so they're never here. +export const roleAssignment = pgTable( + "role_assignment", + { + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + role: text("role").notNull(), // e.g. "catalog-operator" + scopeType: text("scope_type").notNull(), // "catalog" + scopeId: text("scope_id").notNull(), + grantedBy: text("granted_by").references(() => user.id, { onDelete: "set null" }), + createdAt: createdAt(), + }, + (t) => [ + primaryKey({ columns: [t.userId, t.role, t.scopeType, t.scopeId] }), + index("role_assignment_scope").on(t.scopeType, t.scopeId), + ], +); + +// A person's membership of a catalog: asked to join, a member, or banned from it. +export const catalogMember = pgTable( + "catalog_member", + { + catalogId: text("catalog_id") + .notNull() + .references(() => catalog.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + status: text("status").notNull(), // "requested", "member", "banned" + invite: text("invite"), // the invite code they joined with + decidedBy: text("decided_by").references(() => user.id, { onDelete: "set null" }), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => [primaryKey({ columns: [t.catalogId, t.userId] }), index("catalog_member_user").on(t.userId)], +); + +// An invitation to join a catalog: a code, used up to maxUses times (null: no limit) until it expires. +export const catalogInvite = pgTable( + "catalog_invite", + { + code: text("code").primaryKey(), + catalogId: text("catalog_id") + .notNull() + .references(() => catalog.id, { onDelete: "cascade" }), + createdBy: text("created_by").references(() => user.id, { onDelete: "set null" }), + maxUses: integer("max_uses"), + uses: integer("uses").notNull().default(0), + expiresAt: timestamp("expires_at", { withTimezone: true }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + createdAt: createdAt(), + }, + (t) => [index("catalog_invite_catalog").on(t.catalogId)], +); + +// What a catalog must act on (GET /api/v1/catalog/changes): files to delete, revisions to mirror, its status. +export const catalogChange = pgTable( + "catalog_change", + { + id: serial("id").primaryKey(), + catalogId: text("catalog_id") + .notNull() + .references(() => catalog.id, { onDelete: "cascade" }), + data: jsonb("data").$type>().notNull(), // {type, ...} + createdAt: createdAt(), + }, + (t) => [index("catalog_change_catalog").on(t.catalogId, t.id)], +); + // A product: its info, owned by its creator. Its files are in revisions. export const product = pgTable( "product",