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
@@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export { schema };
|
||||
export type { FileEntry } from "./schema";
|
||||
export * from "drizzle-orm";
|
||||
|
||||
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
|
||||
|
||||
@@ -221,3 +221,103 @@ export const audit = pgTable(
|
||||
},
|
||||
(t) => [index("audit_at").on(t.at)],
|
||||
);
|
||||
|
||||
// ---- Products (docs/products.md) ----
|
||||
|
||||
export type FileEntry = { name: string; sha256: string; size: number };
|
||||
|
||||
// The hub's own signing keys (Ed25519) for the tokens catalogs and proxies check. The newest is used.
|
||||
export const signingKey = pgTable("signing_key", {
|
||||
id: text("id").primaryKey(), // the JWK's kid
|
||||
publicKey: jsonb("public_key").$type<Record<string, string>>().notNull(),
|
||||
privateKey: jsonb("private_key").$type<Record<string, string>>().notNull(),
|
||||
createdAt: createdAt(),
|
||||
});
|
||||
|
||||
// A catalog: a server hosting product files. Hidden until a moderator approves it.
|
||||
export const catalog = pgTable(
|
||||
"catalog",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
ownerId: text("owner_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
url: text("url").notNull(), // its public URL
|
||||
contact: text("contact").notNull(), // takedown and legal contact
|
||||
visibility: text("visibility").notNull().default("public"), // "public", "private"
|
||||
membership: text("membership").notNull().default("open"), // "open", "approval", "invite"
|
||||
status: text("status").notNull().default("pending"), // "pending", "approved", "rejected"
|
||||
keyHash: text("key_hash").notNull(), // sha256 of its key, hex
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [uniqueIndex("catalog_url").on(t.url)],
|
||||
);
|
||||
|
||||
// A product: its info, owned by its creator. Its files are in revisions.
|
||||
export const product = pgTable(
|
||||
"product",
|
||||
{
|
||||
id: text("id").primaryKey(), // 10 of a-z2-7
|
||||
creatorId: text("creator_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "restrict" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description").notNull().default(""), // HTML, already cleaned
|
||||
rating: text("rating").notNull(), // "general", "adult": the highest of its revisions, or raised by the creator
|
||||
latest: integer("latest").notNull().default(0), // the newest revision's number
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index("product_creator").on(t.creatorId), index("product_created").on(t.createdAt)],
|
||||
);
|
||||
|
||||
// A revision: a product's files. Never changes (but can be retracted).
|
||||
export const productRevision = pgTable(
|
||||
"product_revision",
|
||||
{
|
||||
productId: text("product_id")
|
||||
.notNull()
|
||||
.references(() => product.id, { onDelete: "cascade" }),
|
||||
number: integer("number").notNull(),
|
||||
files: jsonb("files").$type<FileEntry[]>().notNull(),
|
||||
rating: text("rating").notNull(),
|
||||
parent: bigint("parent", { mode: "number" }).notNull().default(0), // the IMVU product it derives from
|
||||
notes: text("notes").notNull().default(""),
|
||||
token: text("token").notNull(), // the hub's t5de-revision token
|
||||
uploadedBy: text("uploaded_by").references(() => user.id, { onDelete: "set null" }),
|
||||
retracted: boolean("retracted").notNull().default(false),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.productId, t.number] })],
|
||||
);
|
||||
|
||||
// Which catalogs host which revisions.
|
||||
export const hosting = pgTable(
|
||||
"hosting",
|
||||
{
|
||||
catalogId: text("catalog_id")
|
||||
.notNull()
|
||||
.references(() => catalog.id, { onDelete: "cascade" }),
|
||||
productId: text("product_id").notNull(),
|
||||
number: integer("number").notNull(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.catalogId, t.productId, t.number] }), index("hosting_product").on(t.productId)],
|
||||
);
|
||||
|
||||
// A person's library: products they got, optionally staying on a revision.
|
||||
export const library = pgTable(
|
||||
"library",
|
||||
{
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
productId: text("product_id")
|
||||
.notNull()
|
||||
.references(() => product.id, { onDelete: "cascade" }),
|
||||
stayOn: integer("stay_on"),
|
||||
addedAt: timestamp("added_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.productId] })],
|
||||
);
|
||||
Reference in new issue
Block a user