Product registry: catalogs, revisions, product info and libraries
CI / test (push) Successful in 43s

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 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-29 18:53:52 -07:00
1 parent 7fea63fc4f
commit b4f6fce9df
15 files changed
+2883 -14

No files matched your search

+4 -1
View File
@@ -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"
}
}
+263 -11
View File
@@ -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 <T>(f: () => Promise<T>): Promise<T> => {
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<string, number[]>();
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<typeof createApp>["app"];
+51
View File
@@ -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();
}
+1 -1
View File
@@ -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.
+72
View File
@@ -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<Key>;
constructor(
private readonly db: Database,
private readonly issuer: string,
) {}
private load(): Promise<Key> {
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<string, string>,
privateKey: (await exportJWK(pair.privateKey)) as Record<string, string>,
});
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<string, unknown>, expiresIn?: number): Promise<string> {
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<JWTPayload | undefined> {
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" }] };
}
}
+367
View File
@@ -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<string>();
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 <id>:<key>` header names, if it's approved and the key is right. */
async catalogFor(header: string | null): Promise<Catalog | undefined> {
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<Product> = {};
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));
}
}
+5
View File
@@ -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();
+170
View File
@@ -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<number, ImvuUser>();
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<void>;
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<string, string> = {}) {
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<Person> {
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: "[email protected]",
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: `<p style="color:red;position:fixed">Warm <b>coat</b><script>alert(1)</script><a href="javascript:x">x</a><a href="https://example.test" onclick="x()">site</a><img src="http://x/a.png"></p>`,
});
expect(edited.status).toBe(200);
expect(edited.json.product.description).toBe(
`<p style="color:red">Warm <b>coat</b><a rel="nofollow ugc noopener noreferrer" target="_blank">x</a><a href="https://example.test" rel="nofollow ugc noopener noreferrer" target="_blank">site</a></p>`,
);
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([]);
});
+38 -1
View File
@@ -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/[email protected]", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
"@types/sanitize-html": ["@types/[email protected]", "", { "dependencies": { "htmlparser2": "^12.0" } }, "sha512-kuAbgDCFv418flrp3z4o/4ufLA/5ovmXRH0L419iZCPGO7w00znjeDI5Wy/KZWNIBpDQc7hucCK+J5almB6FyA=="],
"@types/validate-npm-package-name": ["@types/[email protected]", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
"@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
@@ -497,6 +502,8 @@
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"dayjs": ["[email protected]", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="],
"debounce-fn": ["[email protected]", "", { "dependencies": { "mimic-fn": "^3.0.0" } }, "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ=="],
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -517,6 +524,14 @@
"diff": ["[email protected]", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"dom-serializer": ["[email protected]", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="],
"domelementtype": ["[email protected]", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="],
"domhandler": ["[email protected]", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="],
"domutils": ["[email protected]", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="],
"dot-prop": ["[email protected]", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="],
"dotenv": ["[email protected]", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
@@ -535,12 +550,16 @@
"enquirer": ["[email protected]", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
"entities": ["[email protected]", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="],
"env-paths": ["[email protected]", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"esbuild": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["[email protected]", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"esprima": ["[email protected]", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"exact-mirror": ["[email protected]", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
@@ -587,6 +606,8 @@
"graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"htmlparser2": ["[email protected]", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "domutils": "^4.0.2", "entities": "^8.0.0" } }, "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw=="],
"human-signals": ["[email protected]", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"ieee754": ["[email protected]", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -613,6 +634,8 @@
"is-plain-obj": ["[email protected]", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-plain-object": ["[email protected]", "", {}, "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw=="],
"is-regexp": ["[email protected]", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="],
"is-stream": ["[email protected]", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
@@ -643,6 +666,8 @@
"kysely": ["[email protected]", "", {}, "sha512-fmIq+7kv4WbFm2tcfznRzikqB4cuNpgZ40PSU2fAJ2/OO5XhojqnTPrmgrCjYSRJVwBln03XcRXJGnw9H099IQ=="],
"launder": ["[email protected]", "", { "dependencies": { "dayjs": "^1.11.23" } }, "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg=="],
"lightningcss": ["[email protected]", "", { "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": ["[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@@ -723,6 +748,8 @@
"parse-ms": ["[email protected]", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
"parse-srcset": ["[email protected]", "", {}, "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="],
"path-browserify": ["[email protected]", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-exists": ["[email protected]", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
@@ -735,7 +762,7 @@
"pkg-up": ["[email protected]", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="],
"postcss": ["[email protected].23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
"postcss": ["[email protected]2", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="],
"postcss-selector-parser": ["[email protected]", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g=="],
@@ -769,6 +796,8 @@
"run-parallel": ["[email protected]", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"sanitize-html": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -883,6 +912,8 @@
"@shadcn/registry/cn": ["[email protected]", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ=="],
"@shadcn/registry/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
@@ -895,6 +926,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tailwindcss/postcss/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
"conf/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"cross-spawn/which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -909,6 +942,8 @@
"micromatch/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"next/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
"npm-run-path/path-key": ["[email protected]", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"onetime/mimic-fn": ["[email protected]", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
@@ -919,6 +954,8 @@
"shadcn/cn": ["[email protected]", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ=="],
"shadcn/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
"sharp/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"tsx/esbuild": ["[email protected]", "", { "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=="],
+87
View File
@@ -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 <t5de-upload token>`; 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/<sha256>` | 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 <t5de-access token>` with `adult: true` (else 401/403, and `Cache-Control: private`). 404 if the catalog doesn't have it. |
| `GET /api/v1/revisions/<product>/<revision>` | `{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 <catalog ID>:<key>`. 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 <catalog>/api/v1/files/<sha256>`, 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\<id>\`: `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).
+72
View File
@@ -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");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -22,6 +22,13 @@
"when": 1793321456968,
"tag": "0002_rooms",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1793324955738,
"tag": "0003_products",
"breakpoints": true
}
]
}
+1
View File
@@ -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));
+100
View File
@@ -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<Record<string, string>>().notNull(),
privateKey: jsonb("private_key").$type<Record<string, string>>().notNull(),
createdAt: createdAt(),
});
// A catalog: a server hosting product files. Hidden until a moderator approves it.
export const catalog = pgTable(
"catalog",
{
id: text("id").primaryKey(),
ownerId: text("owner_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
name: text("name").notNull(),
url: text("url").notNull(), // its public URL
contact: text("contact").notNull(), // takedown and legal contact
visibility: text("visibility").notNull().default("public"), // "public", "private"
membership: text("membership").notNull().default("open"), // "open", "approval", "invite"
status: text("status").notNull().default("pending"), // "pending", "approved", "rejected"
keyHash: text("key_hash").notNull(), // sha256 of its key, hex
createdAt: createdAt(),
},
(t) => [uniqueIndex("catalog_url").on(t.url)],
);
// A product: its info, owned by its creator. Its files are in revisions.
export const product = pgTable(
"product",
{
id: text("id").primaryKey(), // 10 of a-z2-7
creatorId: text("creator_id")
.notNull()
.references(() => user.id, { onDelete: "restrict" }),
name: text("name").notNull(),
description: text("description").notNull().default(""), // HTML, already cleaned
rating: text("rating").notNull(), // "general", "adult": the highest of its revisions, or raised by the creator
latest: integer("latest").notNull().default(0), // the newest revision's number
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [index("product_creator").on(t.creatorId), index("product_created").on(t.createdAt)],
);
// A revision: a product's files. Never changes (but can be retracted).
export const productRevision = pgTable(
"product_revision",
{
productId: text("product_id")
.notNull()
.references(() => product.id, { onDelete: "cascade" }),
number: integer("number").notNull(),
files: jsonb("files").$type<FileEntry[]>().notNull(),
rating: text("rating").notNull(),
parent: bigint("parent", { mode: "number" }).notNull().default(0), // the IMVU product it derives from
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] })],
);