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

+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"];