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
@@ -14,10 +14,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@t5de/db": "workspace:*",
|
"@t5de/db": "workspace:*",
|
||||||
"better-auth": "1.7.14",
|
"better-auth": "1.7.14",
|
||||||
"elysia": "1.4.30"
|
"elysia": "1.4.30",
|
||||||
|
"jose": "6.2.14",
|
||||||
|
"sanitize-html": "2.18.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.4.4",
|
"@types/bun": "^1.4.4",
|
||||||
|
"@types/sanitize-html": "^2.16.2",
|
||||||
"typescript": "^7.0.2"
|
"typescript": "^7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+263
-11
@@ -9,10 +9,21 @@ import { imvuAgeProvider, imvuResult } from "./age/imvu";
|
|||||||
import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
|
import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
|
||||||
import { Realtime } from "./realtime";
|
import { Realtime } from "./realtime";
|
||||||
import { isAdultRoom, Rooms } from "./rooms";
|
import { isAdultRoom, Rooms } from "./rooms";
|
||||||
|
import { Signer } from "./keys";
|
||||||
|
import { ProductError, Products } from "./products";
|
||||||
|
|
||||||
const { user, session, imvuLink, linkChallenge, audit } = schema;
|
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. */
|
/** An error shown to the person: the website displays message as is. */
|
||||||
export class Problem extends Error {
|
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 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 rooms = roomsService ?? new Rooms(db, imvu);
|
||||||
const realtime =
|
const realtime =
|
||||||
rt ??
|
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 }) });
|
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 ownLink = async (p: Person, imvuId: number) => {
|
||||||
const [l] = await db.select().from(imvuLink).where(and(eq(imvuLink.imvuId, imvuId), eq(imvuLink.userId, p.id)));
|
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.");
|
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).
|
// IMVU rooms whose owners list them, with the T5DE users in each now (those showing they're online).
|
||||||
.get("/rooms", async ({ person: me }) => {
|
.get("/rooms", async ({ person: me }) => {
|
||||||
const viewerAdult =
|
const viewerAdult = await personAdult(me);
|
||||||
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 listed = (await rooms.listed()).filter((r) => viewerAdult || !isAdultRoom(r.room));
|
const listed = (await rooms.listed()).filter((r) => viewerAdult || !isAdultRoom(r.room));
|
||||||
const here = new Map<string, number[]>();
|
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]);
|
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 };
|
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.
|
// Age verification beyond IMVU's: the providers in use, and the person's own checks.
|
||||||
.get(
|
.get(
|
||||||
"/age",
|
"/age",
|
||||||
@@ -583,6 +818,22 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
|||||||
)
|
)
|
||||||
|
|
||||||
// The console.
|
// 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(
|
.get(
|
||||||
"/console",
|
"/console",
|
||||||
async ({ me }) => {
|
async ({ me }) => {
|
||||||
@@ -723,10 +974,11 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms
|
|||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
.all("/api/auth/*", ({ request }) => auth.handler(request))
|
.all("/api/auth/*", ({ request }) => auth.handler(request))
|
||||||
.get("/.well-known/jwks.json", async () => auth.api.getJwks())
|
.get("/.well-known/jwks.json", async () => auth.api.getJwks())
|
||||||
|
.get("/.well-known/t5de-keys.json", async () => signer.jwks())
|
||||||
.get("/health", () => ({ ok: true }))
|
.get("/health", () => ({ ok: true }))
|
||||||
.use(api);
|
.use(api);
|
||||||
|
|
||||||
return { app, realtime, age, rooms };
|
return { app, realtime, age, rooms, products, signer };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type App = ReturnType<typeof createApp>["app"];
|
export type App = ReturnType<typeof createApp>["app"];
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ const webURL = env.WEB_URL ?? "http://localhost:4100";
|
|||||||
|
|
||||||
const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db");
|
const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db");
|
||||||
const auth = createAuth(db, { apiURL, webURL, secret });
|
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" });
|
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.
|
// Age answers that can change (IMVU's) are checked again when they expire, a few at a time.
|
||||||
|
|||||||
@@ -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" }] };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,6 +137,11 @@ export class Realtime {
|
|||||||
if (changed) this.changed(chats);
|
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). */
|
/** Closes a person's connections (banned, or signed out of their computers). */
|
||||||
disconnect(userId: string) {
|
disconnect(userId: string) {
|
||||||
for (const s of this.sessions.values()) if (s.userId === userId) s.close();
|
for (const s of this.sessions.values()) if (s.userId === userId) s.close();
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -14,9 +14,12 @@
|
|||||||
"@t5de/db": "workspace:*",
|
"@t5de/db": "workspace:*",
|
||||||
"better-auth": "1.7.14",
|
"better-auth": "1.7.14",
|
||||||
"elysia": "1.4.30",
|
"elysia": "1.4.30",
|
||||||
|
"jose": "^6.2.14",
|
||||||
|
"sanitize-html": "^2.18.1",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.4.4",
|
"@types/bun": "^1.4.4",
|
||||||
|
"@types/sanitize-html": "^2.16.2",
|
||||||
"typescript": "^7.0.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/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=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"dot-prop": ["[email protected]", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="],
|
||||||
|
|
||||||
"dotenv": ["[email protected]", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"human-signals": ["[email protected]", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||||
|
|
||||||
"ieee754": ["[email protected]", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
"ieee754": ["[email protected]", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
@@ -613,6 +634,8 @@
|
|||||||
|
|
||||||
"is-plain-obj": ["[email protected]", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
"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-regexp": ["[email protected]", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="],
|
||||||
|
|
||||||
"is-stream": ["[email protected]", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
"is-stream": ["[email protected]", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
||||||
@@ -643,6 +666,8 @@
|
|||||||
|
|
||||||
"kysely": ["[email protected]", "", {}, "sha512-fmIq+7kv4WbFm2tcfznRzikqB4cuNpgZ40PSU2fAJ2/OO5XhojqnTPrmgrCjYSRJVwBln03XcRXJGnw9H099IQ=="],
|
"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": ["[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=="],
|
"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-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-browserify": ["[email protected]", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||||
|
|
||||||
"path-exists": ["[email protected]", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"scheduler": ["[email protected]", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
|
||||||
|
|
||||||
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"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/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/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=="],
|
"@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/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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"npm-run-path/path-key": ["[email protected]", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||||
|
|
||||||
"onetime/mimic-fn": ["[email protected]", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
"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/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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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
@@ -22,6 +22,13 @@
|
|||||||
"when": 1793321456968,
|
"when": 1793321456968,
|
||||||
"tag": "0002_rooms",
|
"tag": "0002_rooms",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793324955738,
|
||||||
|
"tag": "0003_products",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import * as schema from "./schema";
|
import * as schema from "./schema";
|
||||||
|
|
||||||
export { schema };
|
export { schema };
|
||||||
|
export type { FileEntry } from "./schema";
|
||||||
export * from "drizzle-orm";
|
export * from "drizzle-orm";
|
||||||
|
|
||||||
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
|
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
|
||||||
|
|||||||
@@ -221,3 +221,103 @@ export const audit = pgTable(
|
|||||||
},
|
},
|
||||||
(t) => [index("audit_at").on(t.at)],
|
(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] })],
|
||||||
|
);
|
||||||
Reference in new issue
Block a user