Product registry: catalogs, revisions, product info and libraries
CI / test (push) Successful in 43s
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:
1 parent
7fea63fc4f
commit
b4f6fce9df
15 files changed
+2883
-14
No files matched your search
+263
-11
@@ -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"];
|
||||
Reference in new issue
Block a user