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

+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));
}
}