From b4f6fce9df3ad89364a8e24b892ec1418bd18264 Mon Sep 17 00:00:00 2001 From: dhkatz Date: Thu, 29 Oct 2026 18:53:52 -0700 Subject: [PATCH] Product registry: catalogs, revisions, product info and libraries The hub registers catalogs (hidden until a moderator approves them), hands out upload tokens, and records the revisions catalogs register after validating uploads: numbered, never changed, signed by the hub (its own Ed25519 key, published at /.well-known/t5de-keys.json). Creators edit product info (descriptions cleaned by an allowlist) and can retract revisions. People get products into their library, optionally staying on a revision; the proxy asks which revision to load and where. Contract in docs/products.md. Co-Authored-By: Claude Opus 5.5 --- apps/api/package.json | 5 +- apps/api/src/app.ts | 274 ++- apps/api/src/html.ts | 51 + apps/api/src/index.ts | 2 +- apps/api/src/keys.ts | 72 + apps/api/src/products.ts | 367 +++++ apps/api/src/realtime.ts | 5 + apps/api/test/products.test.ts | 170 ++ bun.lock | 39 +- docs/products.md | 87 + packages/db/drizzle/0003_products.sql | 72 + packages/db/drizzle/meta/0003_snapshot.json | 1645 +++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/index.ts | 1 + packages/db/src/schema.ts | 100 ++ 15 files changed, 2883 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/html.ts create mode 100644 apps/api/src/keys.ts create mode 100644 apps/api/src/products.ts create mode 100644 apps/api/test/products.test.ts create mode 100644 docs/products.md create mode 100644 packages/db/drizzle/0003_products.sql create mode 100644 packages/db/drizzle/meta/0003_snapshot.json diff --git a/apps/api/package.json b/apps/api/package.json index b394060..74f8773 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,10 +14,13 @@ "dependencies": { "@t5de/db": "workspace:*", "better-auth": "1.7.14", - "elysia": "1.4.30" + "elysia": "1.4.30", + "jose": "6.2.14", + "sanitize-html": "2.18.1" }, "devDependencies": { "@types/bun": "^1.4.4", + "@types/sanitize-html": "^2.16.2", "typescript": "^7.0.2" } } diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index e511d6a..8adf287 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -9,10 +9,21 @@ import { imvuAgeProvider, imvuResult } from "./age/imvu"; import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu"; import { Realtime } from "./realtime"; import { isAdultRoom, Rooms } from "./rooms"; +import { Signer } from "./keys"; +import { ProductError, Products } from "./products"; const { user, session, imvuLink, linkChallenge, audit } = schema; -export type Deps = { db: Database; auth: Auth; imvu: ImvuClient; realtime?: Realtime; age?: AgeService; rooms?: Rooms }; +export type Deps = { + db: Database; + auth: Auth; + imvu: ImvuClient; + realtime?: Realtime; + age?: AgeService; + rooms?: Rooms; + /** The hub's API URL, the issuer of its tokens. */ + apiURL?: string; +}; /** An error shown to the person: the website displays message as is. */ export class Problem extends Error { @@ -62,8 +73,10 @@ class Limiter { } } -export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms: roomsService }: Deps) { +export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms: roomsService, apiURL }: Deps) { const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]); + const signer = new Signer(db, apiURL ?? "http://localhost:4101"); + const products = new Products(db, signer); const rooms = roomsService ?? new Rooms(db, imvu); const realtime = rt ?? @@ -148,6 +161,52 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms const imvuIdParam = t.Object({ imvu: t.Numeric({ minimum: 1 }) }); + /** Whether any of a person's IMVU accounts counts as adult (for seeing adult products and rooms). */ + const personAdult = async (p: Person | undefined) => { + if (!p) return false; + const ids = (await db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.userId, p.id))).map((l) => l.id); + return ids.length > 0 && (await age.adultAccounts(p.id, ids)).size > 0; + }; + + /** Runs a product operation, turning its errors into Problems. */ + const productCall = async (f: () => Promise): Promise => { + try { + return await f(); + } catch (e) { + if (e instanceof ProductError) throw new Problem(e.status, e.slug, e.message); + throw e; + } + }; + + const productView = ( + p: typeof schema.product.$inferSelect, + creator?: string | null, + creatorUsername?: string | null, + ) => ({ + id: p.id, + name: p.name, + description: p.description, + rating: p.rating as "general" | "adult", + latest: p.latest, + creator: creator ?? creatorUsername ?? "", + creatorUsername: creatorUsername ?? "", + createdAt: p.createdAt.toISOString(), + updatedAt: p.updatedAt.toISOString(), + }); + + const catalogView = (c: typeof schema.catalog.$inferSelect) => ({ + id: c.id, + name: c.name, + url: c.url, + contact: c.contact, + visibility: c.visibility, + membership: c.membership, + status: c.status, + createdAt: c.createdAt.toISOString(), + }); + + const productParam = t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }) }); + const ownLink = async (p: Person, imvuId: number) => { const [l] = await db.select().from(imvuLink).where(and(eq(imvuLink.imvuId, imvuId), eq(imvuLink.userId, p.id))); if (!l) throw new Problem(404, "not_found", "That account isn't linked to you."); @@ -470,14 +529,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms // IMVU rooms whose owners list them, with the T5DE users in each now (those showing they're online). .get("/rooms", async ({ person: me }) => { - const viewerAdult = - me !== undefined && - ( - await age.adultAccounts( - me.id, - (await db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.userId, me.id))).map((l) => l.id), - ) - ).size > 0; + const viewerAdult = await personAdult(me); const listed = (await rooms.listed()).filter((r) => viewerAdult || !isAdultRoom(r.room)); const here = new Map(); for (const o of realtime.online()) for (const r of o.rooms) here.set(r, [...(here.get(r) ?? []), o.imvuId]); @@ -508,6 +560,189 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms return { rooms: out }; }) + // Catalogs: registering one, and the directory. + .post( + "/catalogs", + async ({ me, body, set }) => { + const r = await productCall(() => products.registerCatalog(me.id, body)); + await audited(me.id, "catalog.register", r.catalog.id, { name: r.catalog.name, url: r.catalog.url }); + set.status = 201; + return { catalog: catalogView(r.catalog), key: r.key }; + }, + { + signedIn: true, + body: t.Object({ + name: t.String({ maxLength: 64 }), + url: t.String({ maxLength: 300 }), + contact: t.String({ maxLength: 200 }), + visibility: t.String(), + membership: t.String(), + }), + }, + ) + .get("/catalogs", async ({ person }) => ({ + catalogs: (await products.catalogs(person?.id)).map((c) => ({ ...catalogView(c), mine: c.ownerId === person?.id })), + })) + + // For catalogs: registering an uploaded revision. + .post( + "/catalog/revisions", + async ({ request, body, set }) => { + const c = await products.catalogFor(request.headers.get("authorization")); + if (!c) throw new Problem(401, "forbidden", "Not an approved catalog."); + const r = await productCall(() => products.registerRevision(c, body)); + set.status = 201; + return r; + }, + { + body: t.Object({ + uploadToken: t.String({ maxLength: 4000 }), + files: t.Array(t.Object({ name: t.String(), sha256: t.String(), size: t.Number() }), { maxItems: 512 }), + rating: t.String(), + parent: t.Number(), + name: t.String({ maxLength: 200 }), + notes: t.String({ maxLength: 4000 }), + }), + }, + ) + + // Products. + .post( + "/products/upload", + async ({ me, body }) => productCall(() => products.uploadToken(me.id, body.catalog, body.product)), + { signedIn: true, body: t.Object({ catalog: t.String(), product: t.Optional(t.String()) }) }, + ) + .get( + "/products", + async ({ person, query }) => { + const adult = await personAdult(person); + const rows = await products.list({ q: query.q, creator: query.creator, adult }); + return { products: rows.map((r) => productView(r.product, r.creator, r.creatorUsername)) }; + }, + { query: t.Object({ q: t.Optional(t.String({ maxLength: 100 })), creator: t.Optional(t.String({ maxLength: 64 })) }) }, + ) + .get( + "/products/:id", + async ({ person, params }) => { + const adult = await personAdult(person); + const p = await productCall(() => products.get(params.id, adult)); + const lib = person ? await products.inLibrary(person.id, params.id) : undefined; + return { + product: productView(p.product, p.creator, p.creatorUsername), + revisions: p.revisions.map((r) => ({ + number: r.number, + rating: r.rating, + notes: r.notes, + retracted: r.retracted, + files: r.files, + size: r.files.reduce((n, f) => n + f.size, 0), + createdAt: r.createdAt.toISOString(), + catalogs: p.hosts.filter((h) => h.number === r.number).map((h) => h.name), + })), + inLibrary: lib !== undefined, + stayOn: lib?.stayOn ?? null, + mine: person?.id === p.product.creatorId, + }; + }, + { params: productParam }, + ) + .patch( + "/products/:id", + async ({ me, params, body }) => { + const p = await productCall(() => products.update(me.id, params.id, body)); + await audited(me.id, "product.edit", p.id, { fields: Object.keys(body) }); + return { product: productView(p) }; + }, + { + signedIn: true, + params: productParam, + body: t.Object({ + name: t.Optional(t.String({ maxLength: 64 })), + description: t.Optional(t.String({ maxLength: 20000 })), + rating: t.Optional(t.Union([t.Literal("general"), t.Literal("adult")])), + }), + }, + ) + .post( + "/products/:id/revisions/:n/retract", + async ({ me, params }) => { + await productCall(() => products.retract(me.id, params.id, params.n)); + await audited(me.id, "product.retract", params.id, { revision: params.n }); + return { ok: true }; + }, + { signedIn: true, params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), n: t.Numeric({ minimum: 1 }) }) }, + ) + // What the proxy loads: the revision this person uses, where to get it, and the hub's word on its files. + .get( + "/products/:id/files", + async ({ person, params }) => { + const r = await productCall(async () => products.revisionFor(person?.id, params.id, await personAdult(person))); + return { + product: r.product.id, + revision: r.revision.number, + rating: r.revision.rating, + parent: r.revision.parent, + name: r.product.name, + creator: r.creator ?? "", + files: r.revision.files, + catalogs: r.catalogs, + token: r.revision.token, + }; + }, + { params: productParam }, + ) + .post( + "/access", + async ({ me }) => ({ + token: await signer.sign("t5de-access", { sub: me.id, adult: await personAdult(me) }, 60 * 60), + }), + { signedIn: true }, + ) + + // Libraries. + .get( + "/library", + async ({ me }) => ({ + library: (await products.library(me.id)).map((l) => ({ + product: productView(l.product), + stayOn: l.stayOn, + addedAt: l.addedAt.toISOString(), + })), + }), + { signedIn: true }, + ) + .put( + "/library/:id", + async ({ me, params }) => { + await productCall(async () => products.add(me.id, params.id, await personAdult(me))); + realtime.notify(me.id, { type: "library" }); + return { ok: true }; + }, + { signedIn: true, params: productParam }, + ) + .delete( + "/library/:id", + async ({ me, params, set }) => { + await products.remove(me.id, params.id); + realtime.notify(me.id, { type: "library" }); + set.status = 204; + }, + { signedIn: true, params: productParam }, + ) + .patch( + "/library/:id", + async ({ me, params, body }) => { + await productCall(() => products.stayOn(me.id, params.id, body.stayOn)); + realtime.notify(me.id, { type: "library" }); + return { ok: true }; + }, + { + signedIn: true, + params: productParam, + body: t.Object({ stayOn: t.Union([t.Integer({ minimum: 1 }), t.Null()]) }), + }, + ) + // Age verification beyond IMVU's: the providers in use, and the person's own checks. .get( "/age", @@ -583,6 +818,22 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms ) // The console. + .get( + "/console/catalogs", + async () => ({ catalogs: (await products.allCatalogs()).map(catalogView) }), + { moderator: true }, + ) + .post( + "/console/catalogs/:id/:decision", + async ({ me, params }) => { + const c = await productCall(() => + products.setCatalogStatus(params.id, params.decision === "approve" ? "approved" : "rejected"), + ); + await audited(me.id, `catalog.${params.decision}`, c.id, { name: c.name }); + return { catalog: catalogView(c) }; + }, + { moderator: true, params: t.Object({ id: t.String(), decision: t.Union([t.Literal("approve"), t.Literal("reject")]) }) }, + ) .get( "/console", async ({ me }) => { @@ -723,10 +974,11 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms const app = new Elysia() .all("/api/auth/*", ({ request }) => auth.handler(request)) .get("/.well-known/jwks.json", async () => auth.api.getJwks()) + .get("/.well-known/t5de-keys.json", async () => signer.jwks()) .get("/health", () => ({ ok: true })) .use(api); - return { app, realtime, age, rooms }; + return { app, realtime, age, rooms, products, signer }; } export type App = ReturnType["app"]; diff --git a/apps/api/src/html.ts b/apps/api/src/html.ts new file mode 100644 index 0000000..75a5006 --- /dev/null +++ b/apps/api/src/html.ts @@ -0,0 +1,51 @@ +// Product descriptions: HTML, as on IMVU, made safe by an allowlist (docs/design.md, "Product info"). Cleaned when +// saved; whatever shows them cleans again. +import sanitize from "sanitize-html"; + +export const maxDescription = 20_000; + +const color = [/^#[0-9a-f]{3,8}$/i, /^[a-z]{3,20}$/i, /^rgba?\(\s*[\d.\s,%]+\)$/i]; +const size = [/^\d{1,3}(\.\d+)?(px|pt|em|rem|%)$/, /^(xx-small|x-small|small|medium|large|x-large|xx-large)$/]; + +const options: sanitize.IOptions = { + allowedTags: [ + "b", "i", "u", "s", "em", "strong", "small", "big", "sub", "sup", "font", "span", "div", "p", "br", "hr", + "center", "ul", "ol", "li", "table", "thead", "tbody", "tr", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6", + "a", "img", + ], + allowedAttributes: { + a: ["href", "rel", "target"], // rel and target are always set by the transform below + img: ["src", "alt", "width", "height"], + font: ["color", "size"], + td: ["colspan", "rowspan"], + th: ["colspan", "rowspan"], + "*": ["style"], + }, + allowedSchemes: ["http", "https"], + allowedSchemesByTag: { img: ["https"] }, + allowProtocolRelative: false, + allowedStyles: { + "*": { + color, + "background-color": color, + "font-size": size, + "font-weight": [/^(normal|bold|bolder|lighter|[1-9]00)$/], + "font-style": [/^(normal|italic|oblique)$/], + "text-align": [/^(left|right|center|justify)$/], + "text-decoration": [/^(none|underline|line-through|overline)$/], + }, + }, + transformTags: { + a: (tagName, attribs) => ({ + tagName, + attribs: { ...attribs, rel: "nofollow ugc noopener noreferrer", target: "_blank" }, + }), + }, + // Images whose address was refused are dropped. + exclusiveFilter: (frame) => frame.tag === "img" && !frame.attribs.src, + disallowedTagsMode: "discard", +}; + +export function cleanHTML(html: string): string { + return sanitize(html, options).trim(); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 49cb773..73ddf08 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -22,7 +22,7 @@ const webURL = env.WEB_URL ?? "http://localhost:4100"; const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db"); const auth = createAuth(db, { apiURL, webURL, secret }); -const { app, age, rooms } = createApp({ db, auth, imvu: new ImvuClient() }); +const { app, age, rooms } = createApp({ db, auth, imvu: new ImvuClient(), apiURL }); app.listen({ port, hostname: env.HOST ?? "0.0.0.0" }); // Age answers that can change (IMVU's) are checked again when they expire, a few at a time. diff --git a/apps/api/src/keys.ts b/apps/api/src/keys.ts new file mode 100644 index 0000000..95f56d1 --- /dev/null +++ b/apps/api/src/keys.ts @@ -0,0 +1,72 @@ +// The hub's signing key (Ed25519) for the tokens catalogs and proxies check: upload, access and revision tokens +// (docs/products.md). Made on first use and kept in the database; the public half is at +// /.well-known/t5de-keys.json. +import { desc, schema, type Database } from "@t5de/db"; +import { exportJWK, generateKeyPair, importJWK, jwtVerify, SignJWT, type JWK, type JWTPayload } from "jose"; + +const { signingKey } = schema; + +type Key = { kid: string; publicJWK: JWK; privateKey: CryptoKey }; + +export class Signer { + private key?: Promise; + + constructor( + private readonly db: Database, + private readonly issuer: string, + ) {} + + private load(): Promise { + this.key ??= (async () => { + const [row] = await this.db.select().from(signingKey).orderBy(desc(signingKey.createdAt)).limit(1); + if (row) { + return { + kid: row.id, + publicJWK: row.publicKey, + privateKey: (await importJWK(row.privateKey, "EdDSA")) as CryptoKey, + }; + } + const pair = await generateKeyPair("EdDSA", { crv: "Ed25519", extractable: true }); + const publicJWK = await exportJWK(pair.publicKey); + const kid = Buffer.from(crypto.getRandomValues(new Uint8Array(9))).toString("base64url"); + await this.db.insert(signingKey).values({ + id: kid, + publicKey: publicJWK as Record, + privateKey: (await exportJWK(pair.privateKey)) as Record, + }); + return { kid, publicJWK, privateKey: pair.privateKey }; + })(); + return this.key; + } + + /** A token of type typ with these claims; expiresIn in seconds (none: no expiry). */ + async sign(typ: string, claims: Record, expiresIn?: number): Promise { + const k = await this.load(); + let jwt = new SignJWT({ ...claims, typ }) + .setProtectedHeader({ alg: "EdDSA", kid: k.kid, typ: "JWT" }) + .setIssuer(this.issuer) + .setIssuedAt(); + if (expiresIn) jwt = jwt.setExpirationTime(Math.floor(Date.now() / 1000) + expiresIn); + return jwt.sign(k.privateKey); + } + + /** A token's claims, if the hub signed it, it's of type typ, and it hasn't expired; else undefined. */ + async verify(token: string, typ: string): Promise { + const k = await this.load(); + try { + const { payload } = await jwtVerify(token, await importJWK(k.publicJWK, "EdDSA"), { + issuer: this.issuer, + algorithms: ["EdDSA"], + }); + return payload.typ === typ ? payload : undefined; + } catch { + return undefined; + } + } + + /** The public keys, as a JWKS. */ + async jwks() { + const k = await this.load(); + return { keys: [{ ...k.publicJWK, kid: k.kid, alg: "EdDSA", use: "sig" }] }; + } +} diff --git a/apps/api/src/products.ts b/apps/api/src/products.ts new file mode 100644 index 0000000..8f19f17 --- /dev/null +++ b/apps/api/src/products.ts @@ -0,0 +1,367 @@ +// The product registry, catalogs and libraries (docs/products.md). The routes are in app.ts; this is what they do. +import { createHash, timingSafeEqual } from "node:crypto"; +import { and, desc, eq, ilike, inArray, ne, or, sql, schema, type Database, type FileEntry } from "@t5de/db"; +import { cleanHTML, maxDescription } from "./html"; +import type { Signer } from "./keys"; + +const { catalog, product, productRevision, hosting, library, user } = schema; + +export type Catalog = typeof catalog.$inferSelect; +export type Product = typeof product.$inferSelect; +export type Revision = typeof productRevision.$inferSelect; + +/** An error for the person; the routes turn it into a Problem. */ +export class ProductError extends Error { + constructor( + readonly status: number, + readonly slug: string, + message: string, + ) { + super(message); + } +} + +const base32 = "abcdefghijklmnopqrstuvwxyz234567"; +export const newProductID = () => + [...crypto.getRandomValues(new Uint8Array(10))].map((b) => base32[b & 31]).join(""); +const randomKey = () => Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString("base64url"); +const sha256 = (s: string) => createHash("sha256").update(s).digest("hex"); + +const nameRule = (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 file list as catalogs send it: checked and sorted by name. */ +export function checkFiles(files: unknown): FileEntry[] | undefined { + if (!Array.isArray(files) || files.length === 0 || files.length > 512) return undefined; + const out: FileEntry[] = []; + const names = new Set(); + for (const f of files) { + const name = typeof f?.name === "string" ? f.name : ""; + const hash = typeof f?.sha256 === "string" ? f.sha256 : ""; + const size = Number(f?.size); + if (!name || name.length > 200 || names.has(name) || /[/\\]/.test(name)) return undefined; + if (!/^[0-9a-f]{64}$/.test(hash) || !Number.isSafeInteger(size) || size < 0) return undefined; + names.add(name); + out.push({ name, sha256: hash, size }); + } + return out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); +} + +// Field by field: stored lists come back from JSONB with their keys in another order. +const sameFiles = (a: FileEntry[], b: FileEntry[]) => + a.length === b.length && a.every((f, i) => f.name === b[i]!.name && f.sha256 === b[i]!.sha256 && f.size === b[i]!.size); + +export class Products { + constructor( + private readonly db: Database, + private readonly signer: Signer, + ) {} + + // ---- catalogs ---- + + async registerCatalog( + ownerId: string, + input: { name: string; url: string; contact: string; visibility: string; membership: string }, + ) { + const name = nameRule(input.name, 64); + const contact = nameRule(input.contact, 200); + let url: URL; + try { + url = new URL(input.url); + } catch { + throw new ProductError(400, "invalid", "That address isn't a URL."); + } + if (!name || !contact) throw new ProductError(400, "invalid", "A name and a takedown contact are needed."); + if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") + throw new ProductError(400, "invalid", "Catalogs need an https address."); + if (!["public", "private"].includes(input.visibility) || !["open", "approval", "invite"].includes(input.membership)) + throw new ProductError(400, "invalid", "Choose a visibility and a membership."); + const clean = url.origin + url.pathname.replace(/\/+$/, ""); + const [taken] = await this.db.select({ id: catalog.id }).from(catalog).where(eq(catalog.url, clean)); + if (taken) throw new ProductError(409, "taken", "A catalog at that address is already registered."); + const key = randomKey(); + const [c] = await this.db + .insert(catalog) + .values({ + id: newProductID(), + ownerId, + name, + url: clean, + contact, + visibility: input.visibility, + membership: input.membership, + keyHash: sha256(key), + }) + .returning(); + 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 { + 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; + return timingSafeEqual(Buffer.from(sha256(m[2]!)), Buffer.from(c.keyHash)) ? c : undefined; + } + + async catalogs(viewerId?: string) { + const rows = await this.db + .select() + .from(catalog) + .where( + or( + and(eq(catalog.status, "approved"), eq(catalog.visibility, "public")), + viewerId ? eq(catalog.ownerId, viewerId) : sql`false`, + ), + ) + .orderBy(catalog.name); + return rows; + } + + async allCatalogs() { + return this.db.select().from(catalog).orderBy(desc(catalog.createdAt)); + } + + async setCatalogStatus(id: string, status: "approved" | "rejected") { + const [c] = await this.db.update(catalog).set({ status }).where(eq(catalog.id, id)).returning(); + if (!c) throw new ProductError(404, "not_found", "No such catalog."); + return c; + } + + // ---- uploads ---- + + /** An upload token for a person to send a product (or a new revision of theirs) to a catalog. */ + async uploadToken(personId: string, catalogId: string, productId?: string) { + 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); + const token = await this.signer.sign( + "t5de-upload", + { sub: personId, catalog: c.id, ...(productId ? { product: productId } : {}) }, + 30 * 60, + ); + return { token, url: `${c.url}/api/v1/uploads` }; + } + + /** A catalog registers an uploaded revision: a new product, or a new revision of one. */ + async registerRevision( + c: Catalog, + body: { uploadToken: string; files: unknown; rating: string; parent: number; name: string; notes: string }, + ) { + const claims = await this.signer.verify(body.uploadToken, "t5de-upload"); + if (!claims || claims.catalog !== c.id || typeof claims.sub !== "string") + throw new ProductError(401, "bad_token", "The upload token isn't valid for this catalog."); + const files = checkFiles(body.files); + 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."); + 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; + + return this.db.transaction(async (tx) => { + let p: Product; + if (typeof claims.product === "string") { + const [found] = await tx.select().from(product).where(eq(product.id, claims.product)).for("update"); + if (!found || found.creatorId !== personId) throw new ProductError(403, "forbidden", "That product isn't yours."); + if (!notes) throw new ProductError(400, "invalid", "Say what changed in this revision."); + const [latest] = await tx + .select() + .from(productRevision) + .where(and(eq(productRevision.productId, found.id), eq(productRevision.number, found.latest))); + if (latest && sameFiles(latest.files, files)) + throw new ProductError(409, "unchanged", "These files are the same as the latest revision's."); + p = found; + } else { + const name = nameRule(body.name, 64) ?? "Untitled"; + const [made] = await tx + .insert(product) + .values({ id: newProductID(), creatorId: personId, name, rating: body.rating }) + .returning(); + p = made!; + } + const number = p.latest + 1; + const token = await this.signer.sign("t5de-revision", { + product: p.id, + revision: number, + rating: body.rating, + files, + parent, + }); + await tx.insert(productRevision).values({ + productId: p.id, + number, + files, + rating: body.rating, + parent, + notes, + token, + uploadedBy: personId, + }); + // A product is adult if any revision is (or its creator says so). + const rating = p.rating === "adult" || body.rating === "adult" ? "adult" : "general"; + await tx.update(product).set({ latest: number, rating }).where(eq(product.id, p.id)); + await tx.insert(hosting).values({ catalogId: c.id, productId: p.id, number }); + return { product: p.id, revision: number }; + }); + } + + // ---- products ---- + + private async own(personId: string, productId: string) { + const [p] = await this.db.select().from(product).where(eq(product.id, productId)); + if (!p) throw new ProductError(404, "not_found", "No such product."); + if (p.creatorId !== personId) throw new ProductError(403, "forbidden", "That product isn't yours."); + return p; + } + + /** Products, newest first; adult ones only if adult. */ + async list(opts: { q?: string; creator?: string; adult: boolean; limit?: number }) { + const conds = [ne(product.latest, 0)]; + if (!opts.adult) conds.push(eq(product.rating, "general")); + if (opts.q) conds.push(ilike(product.name, `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`)); + if (opts.creator) conds.push(eq(user.username, opts.creator.toLowerCase())); + return this.db + .select({ product, creator: user.displayUsername, creatorUsername: user.username }) + .from(product) + .innerJoin(user, eq(user.id, product.creatorId)) + .where(and(...conds)) + .orderBy(desc(product.createdAt)) + .limit(Math.min(opts.limit ?? 60, 200)); + } + + async get(id: string, adult: boolean) { + const [row] = await this.db + .select({ product, creator: user.displayUsername, creatorUsername: user.username }) + .from(product) + .innerJoin(user, eq(user.id, product.creatorId)) + .where(eq(product.id, id)); + if (!row || row.product.latest === 0) throw new ProductError(404, "not_found", "No such product."); + if (row.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product."); + const revisions = await this.db + .select() + .from(productRevision) + .where(eq(productRevision.productId, id)) + .orderBy(desc(productRevision.number)); + const hosts = await this.db + .select({ number: hosting.number, name: catalog.name, url: catalog.url }) + .from(hosting) + .innerJoin(catalog, eq(catalog.id, hosting.catalogId)) + .where(and(eq(hosting.productId, id), eq(catalog.status, "approved"))); + return { ...row, revisions, hosts }; + } + + async update(personId: string, id: string, input: { name?: string; description?: string; rating?: string }) { + const p = await this.own(personId, id); + const set: Partial = {}; + if (input.name !== undefined) { + const name = nameRule(input.name, 64); + if (!name) throw new ProductError(400, "invalid", "Names are 1-64 characters."); + set.name = name; + } + if (input.description !== undefined) { + if (input.description.length > maxDescription) + throw new ProductError(400, "invalid", "That description is too long."); + set.description = cleanHTML(input.description); + } + if (input.rating !== undefined) { + if (input.rating === "adult") set.rating = "adult"; + else if (input.rating === "general" && p.rating === "adult") { + // Lowering is reviewed first (design.md); refused for now unless every revision is general. + const [adultRev] = await this.db + .select({ n: productRevision.number }) + .from(productRevision) + .where(and(eq(productRevision.productId, id), eq(productRevision.rating, "adult"))) + .limit(1); + if (adultRev) throw new ProductError(400, "invalid", "A product with adult revisions stays adult."); + set.rating = "general"; + } + } + const [updated] = await this.db.update(product).set(set).where(eq(product.id, id)).returning(); + return updated!; + } + + async retract(personId: string, id: string, number: number) { + await this.own(personId, id); + const [r] = await this.db + .update(productRevision) + .set({ retracted: true }) + .where(and(eq(productRevision.productId, id), eq(productRevision.number, number))) + .returning(); + if (!r) throw new ProductError(404, "not_found", "No such revision."); + return r; + } + + // ---- libraries ---- + + async library(personId: string) { + return this.db + .select({ product, stayOn: library.stayOn, addedAt: library.addedAt }) + .from(library) + .innerJoin(product, eq(product.id, library.productId)) + .where(eq(library.userId, personId)) + .orderBy(desc(library.addedAt)); + } + + async inLibrary(personId: string, productId: string) { + const [row] = await this.db + .select({ stayOn: library.stayOn }) + .from(library) + .where(and(eq(library.userId, personId), eq(library.productId, productId))); + return row; + } + + async add(personId: string, productId: string, adult: boolean) { + await this.get(productId, adult); + await this.db.insert(library).values({ userId: personId, productId }).onConflictDoNothing(); + } + + async remove(personId: string, productId: string) { + await this.db.delete(library).where(and(eq(library.userId, personId), eq(library.productId, productId))); + } + + async stayOn(personId: string, productId: string, number: number | null) { + if (number !== null) { + const [r] = await this.db + .select() + .from(productRevision) + .where(and(eq(productRevision.productId, productId), eq(productRevision.number, number))); + if (!r || r.retracted) throw new ProductError(404, "not_found", "No such revision."); + } + const [row] = await this.db + .update(library) + .set({ stayOn: number }) + .where(and(eq(library.userId, personId), eq(library.productId, productId))) + .returning(); + if (!row) throw new ProductError(404, "not_found", "That product isn't in your library."); + } + + /** + * The revision a person uses of a product: the one they stay on, else the latest; a retracted one gives way to + * the nearest earlier one that isn't (or later, if none). + */ + async revisionFor(personId: string | undefined, productId: string, adult: boolean) { + const p = await this.get(productId, adult); + const pin = personId ? (await this.inLibrary(personId, productId))?.stayOn : undefined; + const want = pin ?? p.product.latest; + const usable = p.revisions.filter((r) => !r.retracted); + if (usable.length === 0) throw new ProductError(404, "not_found", "That product has no usable revision."); + const r = + usable.find((r) => r.number === want) ?? + usable.find((r) => r.number < want) ?? + usable[usable.length - 1]!; + const hosts = p.hosts.filter((h) => h.number === r.number).map((h) => h.url); + return { product: p.product, creator: p.creator, revision: r, catalogs: hosts }; + } + + /** People's display names for creators, by ID (for the console). */ + async creators(ids: string[]) { + if (!ids.length) return []; + return this.db.select({ id: user.id, username: user.displayUsername }).from(user).where(inArray(user.id, ids)); + } +} diff --git a/apps/api/src/realtime.ts b/apps/api/src/realtime.ts index d0c2d10..e37bc9f 100644 --- a/apps/api/src/realtime.ts +++ b/apps/api/src/realtime.ts @@ -137,6 +137,11 @@ export class Realtime { if (changed) this.changed(chats); } + /** Sends a message to all of a person's connections. */ + notify(userId: string, message: object) { + for (const s of this.sessions.values()) if (s.userId === userId) s.send(message); + } + /** Closes a person's connections (banned, or signed out of their computers). */ disconnect(userId: string) { for (const s of this.sessions.values()) if (s.userId === userId) s.close(); diff --git a/apps/api/test/products.test.ts b/apps/api/test/products.test.ts new file mode 100644 index 0000000..1cc9a16 --- /dev/null +++ b/apps/api/test/products.test.ts @@ -0,0 +1,170 @@ +// The product registry: catalogs, uploads registered by catalogs, product info, revisions, libraries, tokens. +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { createLocalJWKSet, jwtVerify } from "jose"; +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)); + const u = [...accounts.values()].find( + (u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(), + ); + return Response.json({ + denormalized: u + ? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } } + : {}, + }); +}) as typeof fetch; + +let db: Database; +let close: () => Promise; +let base: string; +let stop: () => void; + +beforeAll(async () => { + ({ db, close } = await openDatabase("memory")); + const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false }); + const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU), apiURL }); + 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 }; +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, imvu?: { id: number; ageVerified: boolean }): Promise { + const res = await fetch(base + "/api/auth/sign-up/email", { + method: "POST", + headers: { Origin: webURL, "Content-Type": "application/json" }, + body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }), + }); + const p = { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ") }; + if (imvu) { + accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: imvu.ageVerified, accessPass: false }); + const started = await call(p, "POST", "/api/v1/account/links", { username: `${name}Av` }); + accounts.get(imvu.id)!.tagline = started.json.challenge.code; + expect((await call(p, "POST", `/api/v1/account/links/${imvu.id}/check`)).status).toBe(200); + } + return p; +} + +const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n }); + +test("catalogs register uploads; products have revisions, info and libraries", async () => { + const admin = await person("admin1"); + const maker = await person("maker", { id: 501, ageVerified: true }); + const kid = await person("kid", { id: 502, ageVerified: false }); + + // A catalog registers; it can't do anything until approved. + const reg = await call(maker, "POST", "/api/v1/catalogs", { + name: "Maker's Shelf", + url: "http://localhost:4102/", + contact: "takedowns@example.test", + visibility: "public", + membership: "open", + }); + expect(reg.status).toBe(201); + const catalogID = reg.json.catalog.id as string; + const auth = { Authorization: `Catalog ${catalogID}:${reg.json.key}` }; + expect(reg.json.catalog.url).toBe("http://localhost:4102"); + expect((await call(undefined, "GET", "/api/v1/catalogs")).json.catalogs).toHaveLength(0); + expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).status).toBe(404); + expect((await call(admin, "POST", `/api/v1/console/catalogs/${catalogID}/approve`)).status).toBe(200); + expect((await call(undefined, "GET", "/api/v1/catalogs")).json.catalogs.map((c: any) => c.name)).toEqual(["Maker's Shelf"]); + + // The maker gets an upload token; the catalog registers what was uploaded with it. + const up = await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID }); + expect(up.json.url).toBe("http://localhost:4102/api/v1/uploads"); + const files = [file("tex.png", 2), file("index.xml", 1)]; + const revision = (token: string, extra: object = {}) => + call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files, rating: "general", parent: 500, name: "Blue Coat", notes: "", ...extra }, auth); + expect((await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: up.json.token, files, rating: "general", parent: 0, name: "x", notes: "" }, { Authorization: `Catalog ${catalogID}:wrong` })).status).toBe(401); + const made = await revision(up.json.token); + expect(made.status).toBe(201); + expect(made.json.revision).toBe(1); + const id = made.json.product as string; + expect(id).toMatch(/^[a-z2-7]{10}$/); + + // Its page: info, revisions (files sorted), hosting. + const page = (await call(undefined, "GET", `/api/v1/products/${id}`)).json; + expect(page.product).toMatchObject({ name: "Blue Coat", rating: "general", latest: 1, creator: "maker" }); + expect(page.revisions[0].files.map((f: any) => f.name)).toEqual(["index.xml", "tex.png"]); + expect(page.revisions[0].catalogs).toEqual(["Maker's Shelf"]); + + // Descriptions are cleaned by the allowlist. + const edited = await call(maker, "PATCH", `/api/v1/products/${id}`, { + description: `

Warm coatxsite

`, + }); + expect(edited.status).toBe(200); + expect(edited.json.product.description).toBe( + `

Warm coatxsite

`, + ); + expect((await call(kid, "PATCH", `/api/v1/products/${id}`, { name: "Mine now" })).status).toBe(403); + + // A new revision needs notes and different files. + const up2 = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID, product: id })).json.token; + expect((await revision(up2)).status).toBe(400); + expect((await revision(up2, { notes: "same" })).status).toBe(409); + const r2 = await revision(up2, { notes: "Brighter", files: [file("index.xml", 1), file("tex.png", 3)] }); + expect(r2.json).toEqual({ product: id, revision: 2 }); + // Someone else's upload token can't add to it. + expect((await call(kid, "POST", "/api/v1/products/upload", { catalog: catalogID, product: id })).status).toBe(403); + + // The library: get it, the proxy resolves it to the latest, or the revision the person stays on. + expect((await call(kid, "PUT", `/api/v1/library/${id}`)).status).toBe(200); + expect((await call(kid, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]); + let files2 = (await call(kid, "GET", `/api/v1/products/${id}/files`)).json; + expect(files2).toMatchObject({ product: id, revision: 2, parent: 500, name: "Blue Coat", creator: "maker", catalogs: ["http://localhost:4102"] }); + expect((await call(kid, "PATCH", `/api/v1/library/${id}`, { stayOn: 1 })).status).toBe(200); + expect((await call(kid, "GET", `/api/v1/products/${id}/files`)).json.revision).toBe(1); + // Retracting revision 1 moves those staying on it to the nearest one that isn't. + await call(maker, "POST", `/api/v1/products/${id}/revisions/1/retract`); + expect((await call(kid, "GET", `/api/v1/products/${id}/files`)).json.revision).toBe(2); + + // The revision token is the hub's signature on the files, checkable with its public keys. + const keys = createLocalJWKSet((await call(undefined, "GET", "/.well-known/t5de-keys.json")).json); + const { payload } = await jwtVerify(files2.token, keys, { issuer: apiURL }); + expect(payload).toMatchObject({ typ: "t5de-revision", product: id, revision: 2, rating: "general", parent: 500 }); + // Access tokens say whether the person is adult. + const access = async (p: Person) => (await jwtVerify((await call(p, "POST", "/api/v1/access")).json.token, keys)).payload; + expect(await access(maker)).toMatchObject({ typ: "t5de-access", adult: true }); + expect(await access(kid)).toMatchObject({ typ: "t5de-access", adult: false }); + + // Adult products are only for adults. + const up3 = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token; + const adultID = (await revision(up3, { rating: "adult", name: "Lace" })).json.product; + expect((await call(maker, "GET", `/api/v1/products/${adultID}`)).status).toBe(200); + expect((await call(kid, "GET", `/api/v1/products/${adultID}`)).status).toBe(404); + expect((await call(kid, "PUT", `/api/v1/library/${adultID}`)).status).toBe(404); + expect((await call(undefined, "GET", "/api/v1/products")).json.products.map((p: any) => p.name)).toEqual(["Blue Coat"]); + expect((await call(maker, "GET", "/api/v1/products?q=lac")).json.products.map((p: any) => p.name)).toEqual(["Lace"]); + + expect((await call(kid, "DELETE", `/api/v1/library/${id}`)).status).toBe(204); + expect((await call(kid, "GET", "/api/v1/library")).json.library).toEqual([]); +}); diff --git a/bun.lock b/bun.lock index 112a8c7..9bae489 100644 --- a/bun.lock +++ b/bun.lock @@ -14,9 +14,12 @@ "@t5de/db": "workspace:*", "better-auth": "1.7.14", "elysia": "1.4.30", + "jose": "^6.2.14", + "sanitize-html": "^2.18.1", }, "devDependencies": { "@types/bun": "^1.4.4", + "@types/sanitize-html": "^2.16.2", "typescript": "^7.0.2", }, }, @@ -391,6 +394,8 @@ "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], + "@types/sanitize-html": ["@types/sanitize-html@2.16.2", "", { "dependencies": { "htmlparser2": "^12.0" } }, "sha512-kuAbgDCFv418flrp3z4o/4ufLA/5ovmXRH0L419iZCPGO7w00znjeDI5Wy/KZWNIBpDQc7hucCK+J5almB6FyA=="], + "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], @@ -497,6 +502,8 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="], + "debounce-fn": ["debounce-fn@4.0.0", "", { "dependencies": { "mimic-fn": "^3.0.0" } }, "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -517,6 +524,14 @@ "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="], + + "domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="], + + "domhandler": ["domhandler@6.0.1", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="], + + "domutils": ["domutils@4.0.2", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="], + "dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="], "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], @@ -535,12 +550,16 @@ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], + "entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], @@ -587,6 +606,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "htmlparser2": ["htmlparser2@12.0.0", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "domutils": "^4.0.2", "entities": "^8.0.0" } }, "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw=="], + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -613,6 +634,8 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-plain-object": ["is-plain-object@5.1.0", "", {}, "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw=="], + "is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], @@ -643,6 +666,8 @@ "kysely": ["kysely@0.29.7", "", {}, "sha512-fmIq+7kv4WbFm2tcfznRzikqB4cuNpgZ40PSU2fAJ2/OO5XhojqnTPrmgrCjYSRJVwBln03XcRXJGnw9H099IQ=="], + "launder": ["launder@1.7.2", "", { "dependencies": { "dayjs": "^1.11.23" } }, "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -723,6 +748,8 @@ "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + "parse-srcset": ["parse-srcset@1.0.2", "", {}, "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="], + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], "path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], @@ -735,7 +762,7 @@ "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], - "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], + "postcss": ["postcss@8.5.32", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="], "postcss-selector-parser": ["postcss-selector-parser@7.1.7", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g=="], @@ -769,6 +796,8 @@ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "sanitize-html": ["sanitize-html@2.18.1", "", { "dependencies": { "escape-string-regexp": "^4.0.0", "htmlparser2": "^12.0.0", "is-plain-object": "^5.1.0", "launder": "^1.7.2", "parse-srcset": "^1.0.2", "postcss": "^8.5.27" } }, "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig=="], + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -883,6 +912,8 @@ "@shadcn/registry/cn": ["cn@0.2.6", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ=="], + "@shadcn/registry/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], @@ -895,6 +926,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@tailwindcss/postcss/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], + "conf/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -909,6 +942,8 @@ "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "next/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], @@ -919,6 +954,8 @@ "shadcn/cn": ["cn@0.2.6", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ=="], + "shadcn/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], + "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], diff --git a/docs/products.md b/docs/products.md new file mode 100644 index 0000000..67a88d3 --- /dev/null +++ b/docs/products.md @@ -0,0 +1,87 @@ +# Products, catalogs and the library: the contract + +How the hub (registry), catalogs (`t5de-catalog`, file hosting) and T5DE's proxy work together. The design and the +reasons are in `design.md` ("Products and catalogs", "Revisions", "In the client's shop and inventory"); this is +what each side sends. + +## Words + +- **Product**: a T5 product, owned by its creator (a hub person). ID: 10 characters of `a-z2-7` (random), e.g. + `k3mq7xa2pd`. Valid as a pack ID, so the proxy can store a downloaded product as a pack. +- **Revision**: a product's files, numbered 1, 2, 3... Never changes. Its **file list** is + `[{name, sha256, size}]`, sorted by name; `sha256` is lowercase hex. +- **Catalog**: a server storing files by sha256 and serving them. Registered on the hub; hidden until a hub + moderator approves it. +- **Library**: the products a person "got" (free), per person (all their IMVU accounts), optionally staying on a + revision. + +## Tokens + +The hub signs tokens with its own Ed25519 key (EdDSA JWTs, compact form), published as a JWKS at +`GET /.well-known/t5de-keys.json` (`kid` in each token's header). Catalogs fetch it (and again when they see an +unknown `kid`). Claims always include `iss` (the hub's API URL), `typ` (below) and `iat`. + +| `typ` | Other claims | Lifetime | Use | +| --- | --- | --- | --- | +| `t5de-upload` | `sub` (person ID), `catalog` (catalog ID), `product` (optional: a new revision of this product) | 30 min | the creator's browser uploads to the catalog | +| `t5de-access` | `sub` (person ID), `adult` (bool) | 1 h | the proxy fetches adult files from catalogs | +| `t5de-revision` | `product`, `revision`, `rating`, `files` (the file list), `parent` (IMVU product ID or 0) | none (`exp` absent) | the hub vouches for a revision's files | + +## Catalog API (`t5de-catalog`) + +Served at the catalog's public URL. CORS: the hub website's origin may call `POST /api/v1/uploads`. + +| | | +| --- | --- | +| `GET /health` | `{ok: true}` | +| `POST /api/v1/uploads` | `Authorization: Bearer `; multipart form: `file` (the product as a zip: Create Mode's `.chkn`, files at the root) and `notes` (text, required for a new revision of an existing product, optional for the first). The catalog validates the files (t5de-api `pack` rules: names, limits, `index.xml` with `ProductRating` GA/AP), stores each file by sha256, then registers the revision with the hub (below) and answers the hub's answer: 201 `{product, revision}`. Errors: `{error, message}`, 400 for bad files, 401 for a bad token, 502 if the hub refused or failed. | +| `GET /api/v1/files/` | the file's bytes, `Cache-Control: public, max-age=31536000, immutable` and `ETag` = the hash. A file used only by adult revisions needs `Authorization: Bearer ` with `adult: true` (else 401/403, and `Cache-Control: private`). 404 if the catalog doesn't have it. | +| `GET /api/v1/revisions//` | `{product, revision, rating, files}`, for revisions the catalog hosts | + +Configuration (environment): `CATALOG_ADDR` (listen address, default `127.0.0.1:4102`), `CATALOG_DATA` (data folder: +SQLite database and files), `CATALOG_PUBLIC_URL`, `HUB_URL` (the hub's API), `CATALOG_ID` and `CATALOG_KEY` (from +registering on the hub), `WEB_ORIGIN` (the hub website, for CORS). + +## Hub API + +### For catalogs (server to server) + +`Authorization: Catalog :`. Only approved catalogs. + +| | | +| --- | --- | +| `POST /api/v1/catalog/revisions` | `{uploadToken, files, rating ("general" or "adult"), parent (IMVU product ID or 0), name (from index.xml, for a new product), notes}` → 201 `{product, revision}`. The upload token must be for this catalog. Without `product` in the token, a new product is made (owner: the token's person, name from `name`). With it, a new revision of that product (the person must own it; `notes` required). A file list identical to the latest revision's is refused (409 `unchanged`). | + +### For people (website, signed in; the proxy with its bearer token) + +| | | +| --- | --- | +| `POST /api/v1/catalogs` | register a catalog: `{name, url, contact, visibility ("public"/"private"), membership ("open"/"approval"/"invite")}` → 201 `{catalog, key}` (the key is shown once) | +| `GET /api/v1/catalogs` | (anyone) approved public catalogs; the person's own, any status | +| `POST /api/v1/products/upload` | `{catalog, product?}` → `{token, url}`: an upload token and the catalog's upload URL | +| `GET /api/v1/products?q=&creator=` | (anyone) products, newest first; adult ones only for people with an adult account | +| `GET /api/v1/products/:id` | (anyone, adult rule as above) `{product, revisions, inLibrary?, mine?}` | +| `PATCH /api/v1/products/:id` | (creator) `{name?, description?, rating?}`; description is HTML, cleaned by the allowlist; rating may go general → adult | +| `POST /api/v1/products/:id/revisions/:n/retract` | (creator) | +| `GET /api/v1/library` | `{library: [{product, revision, stayOn}]}`: the person's products, each with the revision to use | +| `PUT /api/v1/library/:id` | get a product (add it) | +| `DELETE /api/v1/library/:id` | remove it | +| `PATCH /api/v1/library/:id` | `{stayOn: n or null}` | +| `GET /api/v1/products/:id/files` | for the proxy: `{product, revision, rating, parent, name, files, catalogs: [url], token}` for the revision this person uses (their pin, else the latest that isn't retracted); `token` is the `t5de-revision` token | +| `POST /api/v1/access` | for the proxy: `{token}`, a `t5de-access` token (`adult` if any of the person's IMVU accounts counts as adult) | + +The realtime connection also tells the proxy `{"type": "library"}` when the person's library changes. + +### Console (moderators) + +`GET /api/v1/console/catalogs`, `POST /api/v1/console/catalogs/:id/approve`, `.../reject`. + +## The proxy + +- Fetches the library at connect and on `library` messages; for each product, `GET /api/v1/products/:id/files`, + downloads missing files from a listed catalog (`GET /api/v1/files/`, with an access token for + adult revisions), checks each sha256, and writes the product as a pack in its cache + (`%APPDATA%\T5DE\proxy\hub-products\\`: `t5de-product.json` with the hub's name and rating, and `files/`). + From there it's served like any pack. +- Products others wear in a room that it doesn't have are fetched the same way, then given to the client. +- Library products appear in the client's inventory (see the proxy's README). diff --git a/packages/db/drizzle/0003_products.sql b/packages/db/drizzle/0003_products.sql new file mode 100644 index 0000000..94befef --- /dev/null +++ b/packages/db/drizzle/0003_products.sql @@ -0,0 +1,72 @@ +CREATE TABLE "catalog" ( + "id" text PRIMARY KEY NOT NULL, + "owner_id" text NOT NULL, + "name" text NOT NULL, + "url" text NOT NULL, + "contact" text NOT NULL, + "visibility" text DEFAULT 'public' NOT NULL, + "membership" text DEFAULT 'open' NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "key_hash" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "hosting" ( + "catalog_id" text NOT NULL, + "product_id" text NOT NULL, + "number" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "hosting_catalog_id_product_id_number_pk" PRIMARY KEY("catalog_id","product_id","number") +); +--> statement-breakpoint +CREATE TABLE "library" ( + "user_id" text NOT NULL, + "product_id" text NOT NULL, + "stay_on" integer, + "added_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "library_user_id_product_id_pk" PRIMARY KEY("user_id","product_id") +); +--> statement-breakpoint +CREATE TABLE "product" ( + "id" text PRIMARY KEY NOT NULL, + "creator_id" text NOT NULL, + "name" text NOT NULL, + "description" text DEFAULT '' NOT NULL, + "rating" text NOT NULL, + "latest" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "product_revision" ( + "product_id" text NOT NULL, + "number" integer NOT NULL, + "files" jsonb NOT NULL, + "rating" text NOT NULL, + "parent" bigint DEFAULT 0 NOT NULL, + "notes" text DEFAULT '' NOT NULL, + "token" text NOT NULL, + "uploaded_by" text, + "retracted" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "product_revision_product_id_number_pk" PRIMARY KEY("product_id","number") +); +--> statement-breakpoint +CREATE TABLE "signing_key" ( + "id" text PRIMARY KEY NOT NULL, + "public_key" jsonb NOT NULL, + "private_key" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "catalog" ADD CONSTRAINT "catalog_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "hosting" ADD CONSTRAINT "hosting_catalog_id_catalog_id_fk" FOREIGN KEY ("catalog_id") REFERENCES "public"."catalog"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "library" ADD CONSTRAINT "library_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "library" ADD CONSTRAINT "library_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "product" ADD CONSTRAINT "product_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "product_revision" ADD CONSTRAINT "product_revision_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "product_revision" ADD CONSTRAINT "product_revision_uploaded_by_user_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "catalog_url" ON "catalog" USING btree ("url");--> statement-breakpoint +CREATE INDEX "hosting_product" ON "hosting" USING btree ("product_id");--> statement-breakpoint +CREATE INDEX "product_creator" ON "product" USING btree ("creator_id");--> statement-breakpoint +CREATE INDEX "product_created" ON "product" USING btree ("created_at"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0003_snapshot.json b/packages/db/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..9235bae --- /dev/null +++ b/packages/db/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1645 @@ +{ + "id": "acc90916-2852-4ef8-badc-688e381501cb", + "prevId": "77d34434-f3e7-4015-ac31-6333eb46bed0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.age_check": { + "name": "age_check", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "over_eighteen": { + "name": "over_eighteen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "age_check_user": { + "name": "age_check_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "age_check_imvu": { + "name": "age_check_imvu", + "columns": [ + { + "expression": "imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "age_check_provider_ref": { + "name": "age_check_provider_ref", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "age_check_user_id_user_id_fk": { + "name": "age_check_user_id_user_id_fk", + "tableFrom": "age_check", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit": { + "name": "audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "detail": { + "name": "detail", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "audit_at": { + "name": "audit_at", + "columns": [ + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_actor_id_user_id_fk": { + "name": "audit_actor_id_user_id_fk", + "tableFrom": "audit", + "tableTo": "user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog": { + "name": "catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "membership": { + "name": "membership", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_url": { + "name": "catalog_url", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_owner_id_user_id_fk": { + "name": "catalog_owner_id_user_id_fk", + "tableFrom": "catalog", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_code_user_id_user_id_fk": { + "name": "device_code_user_id_user_id_fk", + "tableFrom": "device_code", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosting": { + "name": "hosting", + "schema": "", + "columns": { + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosting_product": { + "name": "hosting_product", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosting_catalog_id_catalog_id_fk": { + "name": "hosting_catalog_id_catalog_id_fk", + "tableFrom": "hosting", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosting_catalog_id_product_id_number_pk": { + "name": "hosting_catalog_id_product_id_number_pk", + "columns": [ + "catalog_id", + "product_id", + "number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.imvu_link": { + "name": "imvu_link", + "schema": "", + "columns": { + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pairwise_id": { + "name": "pairwise_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "show_badge": { + "name": "show_badge", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_presence": { + "name": "show_presence", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "list_rooms": { + "name": "list_rooms", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "imvu_link_user": { + "name": "imvu_link_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "imvu_link_user_id_user_id_fk": { + "name": "imvu_link_user_id_user_id_fk", + "tableFrom": "imvu_link", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "imvu_link_pairwise_id_unique": { + "name": "imvu_link_pairwise_id_unique", + "nullsNotDistinct": false, + "columns": [ + "pairwise_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.library": { + "name": "library", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stay_on": { + "name": "stay_on", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "added_at": { + "name": "added_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "library_user_id_user_id_fk": { + "name": "library_user_id_user_id_fk", + "tableFrom": "library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "library_product_id_product_id_fk": { + "name": "library_product_id_product_id_fk", + "tableFrom": "library", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "library_user_id_product_id_pk": { + "name": "library_user_id_product_id_pk", + "columns": [ + "user_id", + "product_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link_challenge": { + "name": "link_challenge", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "link_challenge_user_id_user_id_fk": { + "name": "link_challenge_user_id_user_id_fk", + "tableFrom": "link_challenge", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "link_challenge_user_id_imvu_id_pk": { + "name": "link_challenge_user_id_imvu_id_pk", + "columns": [ + "user_id", + "imvu_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product": { + "name": "product", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest": { + "name": "latest", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_creator": { + "name": "product_creator", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_created": { + "name": "product_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_creator_id_user_id_fk": { + "name": "product_creator_id_user_id_fk", + "tableFrom": "product", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_revision": { + "name": "product_revision", + "schema": "", + "columns": { + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent": { + "name": "parent", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retracted": { + "name": "retracted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "product_revision_product_id_product_id_fk": { + "name": "product_revision_product_id_product_id_fk", + "tableFrom": "product_revision", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "product_revision_uploaded_by_user_id_fk": { + "name": "product_revision_uploaded_by_user_id_fk", + "tableFrom": "product_revision", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "product_revision_product_id_number_pk": { + "name": "product_revision_product_id_number_pk", + "columns": [ + "product_id", + "number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.room": { + "name": "room", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_imvu_id": { + "name": "owner_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "privacy": { + "name": "privacy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_rating": { + "name": "content_rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "access_pass": { + "name": "access_pass", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "occupancy": { + "name": "occupancy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expired": { + "name": "expired", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "room_owner": { + "name": "room_owner", + "columns": [ + { + "expression": "owner_imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user": { + "name": "session_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signing_key": { + "name": "signing_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "link_cap": { + "name": "link_cap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + } + }, + "indexes": { + "user_username_lower": { + "name": "user_username_lower", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier": { + "name": "verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 06c9340..1c1a8f5 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1793321456968, "tag": "0002_rooms", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1793324955738, + "tag": "0003_products", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 39ff103..760c0a6 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url"; import * as schema from "./schema"; export { schema }; +export type { FileEntry } 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 4feb049..560aebb 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -221,3 +221,103 @@ export const audit = pgTable( }, (t) => [index("audit_at").on(t.at)], ); + +// ---- Products (docs/products.md) ---- + +export type FileEntry = { name: string; sha256: string; size: number }; + +// The hub's own signing keys (Ed25519) for the tokens catalogs and proxies check. The newest is used. +export const signingKey = pgTable("signing_key", { + id: text("id").primaryKey(), // the JWK's kid + publicKey: jsonb("public_key").$type>().notNull(), + privateKey: jsonb("private_key").$type>().notNull(), + createdAt: createdAt(), +}); + +// A catalog: a server hosting product files. Hidden until a moderator approves it. +export const catalog = pgTable( + "catalog", + { + id: text("id").primaryKey(), + ownerId: text("owner_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + name: text("name").notNull(), + url: text("url").notNull(), // its public URL + contact: text("contact").notNull(), // takedown and legal contact + visibility: text("visibility").notNull().default("public"), // "public", "private" + membership: text("membership").notNull().default("open"), // "open", "approval", "invite" + status: text("status").notNull().default("pending"), // "pending", "approved", "rejected" + keyHash: text("key_hash").notNull(), // sha256 of its key, hex + createdAt: createdAt(), + }, + (t) => [uniqueIndex("catalog_url").on(t.url)], +); + +// A product: its info, owned by its creator. Its files are in revisions. +export const product = pgTable( + "product", + { + id: text("id").primaryKey(), // 10 of a-z2-7 + creatorId: text("creator_id") + .notNull() + .references(() => user.id, { onDelete: "restrict" }), + name: text("name").notNull(), + description: text("description").notNull().default(""), // HTML, already cleaned + rating: text("rating").notNull(), // "general", "adult": the highest of its revisions, or raised by the creator + latest: integer("latest").notNull().default(0), // the newest revision's number + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => [index("product_creator").on(t.creatorId), index("product_created").on(t.createdAt)], +); + +// A revision: a product's files. Never changes (but can be retracted). +export const productRevision = pgTable( + "product_revision", + { + productId: text("product_id") + .notNull() + .references(() => product.id, { onDelete: "cascade" }), + number: integer("number").notNull(), + files: jsonb("files").$type().notNull(), + rating: text("rating").notNull(), + parent: bigint("parent", { mode: "number" }).notNull().default(0), // the IMVU product it derives from + notes: text("notes").notNull().default(""), + token: text("token").notNull(), // the hub's t5de-revision token + uploadedBy: text("uploaded_by").references(() => user.id, { onDelete: "set null" }), + retracted: boolean("retracted").notNull().default(false), + createdAt: createdAt(), + }, + (t) => [primaryKey({ columns: [t.productId, t.number] })], +); + +// Which catalogs host which revisions. +export const hosting = pgTable( + "hosting", + { + catalogId: text("catalog_id") + .notNull() + .references(() => catalog.id, { onDelete: "cascade" }), + productId: text("product_id").notNull(), + number: integer("number").notNull(), + createdAt: createdAt(), + }, + (t) => [primaryKey({ columns: [t.catalogId, t.productId, t.number] }), index("hosting_product").on(t.productId)], +); + +// A person's library: products they got, optionally staying on a revision. +export const library = pgTable( + "library", + { + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + productId: text("product_id") + .notNull() + .references(() => product.id, { onDelete: "cascade" }), + stayOn: integer("stay_on"), + addedAt: timestamp("added_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [primaryKey({ columns: [t.userId, t.productId] })], +);