Files
t5de-hub/docs/products.md
T
dhkatz 7299e10b08
Release / release (push) Successful in 3s
CI / test (push) Successful in 17m26s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 6s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 15s
feat: cases (reports, reviews, requests, appeals) and notifications (#9)
Phase 3, part 1 (`docs/design.md`, "Next"): cases and notifications. File blocks, API tokens and two-factor sign-in come as separate PRs.

**Cases**
- One queue (Console → Cases) for everything someone has to decide. Each case has:
  - a thread of notes, where internal notes are visible only to those deciding
  - an assignee (moderators assign cases to moderators)
  - a decision, Resolve or Decline, with a written resolution that the person who opened it sees
- **Reports:** anyone signed in can report a product or catalog from its page (the API also takes rooms and people).
  - Limits: 10 an hour, one open report per person and thing, and you can't report yourself.
  - Reports about a product go to the teams of the catalogs hosting it, as well as to moderators.
  - Only the reporter and moderators know who reported. The audit entry for a report isn't tagged with what it's about, so a product's creator can't find out from their audit log either.
- **Rating reviews:** a creator lowering a rating from adult to general now opens a review instead of being refused. The rest of the edit still saves. Resolving the review lowers the rating, recorded in the product's history.
- **Removal requests:** a catalog's team can ask moderators to remove a product it hosts, everywhere. Resolving it removes the product.
- **Deletion requests:** a catalog's owner asks for deletion from the catalog's console page, and a moderator decides, as agreed. Resolving it marks the catalog `deleted`: its key stops working, nothing is available from it, and its changes feed says so.
- **Appeals:**
  - From the audit log, the person an action affected can appeal it, once: their product removed, delisted or edited by someone else; their catalog suspended or rejected; a ban from a catalog or a declined request to join.
  - Another moderator decides. An admin may decide an appeal of their own action, and the audit log flags it.
  - Resolving records the decision; the moderator then undoes the action where it's done. For that, there's now a Restore for removed products (Console → Products, for moderators); the design mentioned one, but it was never built.
- The console shows a "Cases" tab, and a to-do with the number of open cases the person could decide.

**Notifications**
- A bell in the header with the unread count, and a notifications page with "Mark all as read".
- Each is also sent to the person's proxies over the realtime connection (`{"type": "notification"}`), so the client can show them later.
- They cover:
  - a moderator edited or locked your product, or undid a change (with the reason)
  - your product was removed or restored
  - your case was decided
  - someone replied on your case
  - a catalog decided your membership

**Also changed**
- A product's creator and moderators can now see it when it's adult, even without an adult account. Before, a creator who made their own product adult couldn't open its page any more, and neither could moderators. Getting and wearing adult products still need an adult account.

**Not done yet** (in `docs/console.md`): email notifications (the hub sends no email yet), canned reasons, private moderator notes on people and products, and second approval for the most destructive actions.

**Migration** `0012_cases`: the `moderation_case`, `case_note` and `notification` tables. (`case` is a reserved word in SQL, hence the table's name.)

**Checks**
- 51 API tests pass. The 7 in the new `cases.test.ts` cover:
  - who sees and decides each kind of case, and that the reporter stays private
  - rate limits and duplicate reports
  - the thread and internal notes, and assigning
  - what deciding each kind of case does
  - appeals by the person affected only, decided by another moderator
  - restoring a removed product
  - the notifications
- Type checks pass.
- On a local hub I went through every flow with the website's own forms: report from a product page; the catalog owner's note and decision; assigning, an internal note and a decision as admin; removal by a moderator, the creator's appeal from the audit log, the admin's decision and Restore; a creator's rating review and the decision lowering the rating; a deletion request and its decline; the notifications, the bell's count, and Mark all as read.
- Fixed along the way:
  - The catalog's team could see who reported.
  - A missing Resolve/Decline choice was treated as Decline; now it's refused.
  - The Unassign button sent the typed name instead of nothing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Phase 3, part 1 (`docs/design.md`, "Next"): cases and notifications. File blocks, API tokens and two-factor sign-in come as separate PRs.

**Cases**
- One queue (Console → Cases) for everything someone has to decide. Each case has:
  - a thread of notes, where internal notes are visible only to those deciding
  - an assignee (moderators assign cases to moderators)
  - a decision, Resolve or Decline, with a written resolution that the person who opened it sees
- **Reports:** anyone signed in can report a product or catalog from its page (the API also takes rooms and people).
  - Limits: 10 an hour, one open report per person and thing, and you can't report yourself.
  - Reports about a product go to the teams of the catalogs hosting it, as well as to moderators.
  - Only the reporter and moderators know who reported. The audit entry for a report isn't tagged with what it's about, so a product's creator can't find out from their audit log either.
- **Rating reviews:** a creator lowering a rating from adult to general now opens a review instead of being refused. The rest of the edit still saves. Resolving the review lowers the rating, recorded in the product's history.
- **Removal requests:** a catalog's team can ask moderators to remove a product it hosts, everywhere. Resolving it removes the product.
- **Deletion requests:** a catalog's owner asks for deletion from the catalog's console page, and a moderator decides, as agreed. Resolving it marks the catalog `deleted`: its key stops working, nothing is available from it, and its changes feed says so.
- **Appeals:**
  - From the audit log, the person an action affected can appeal it, once: their product removed, delisted or edited by someone else; their catalog suspended or rejected; a ban from a catalog or a declined request to join.
  - Another moderator decides. An admin may decide an appeal of their own action, and the audit log flags it.
  - Resolving records the decision; the moderator then undoes the action where it's done. For that, there's now a Restore for removed products (Console → Products, for moderators); the design mentioned one, but it was never built.
- The console shows a "Cases" tab, and a to-do with the number of open cases the person could decide.

**Notifications**
- A bell in the header with the unread count, and a notifications page with "Mark all as read".
- Each is also sent to the person's proxies over the realtime connection (`{"type": "notification"}`), so the client can show them later.
- They cover:
  - a moderator edited or locked your product, or undid a change (with the reason)
  - your product was removed or restored
  - your case was decided
  - someone replied on your case
  - a catalog decided your membership

**Also changed**
- A product's creator and moderators can now see it when it's adult, even without an adult account. Before, a creator who made their own product adult couldn't open its page any more, and neither could moderators. Getting and wearing adult products still need an adult account.

**Not done yet** (in `docs/console.md`): email notifications (the hub sends no email yet), canned reasons, private moderator notes on people and products, and second approval for the most destructive actions.

**Migration** `0012_cases`: the `moderation_case`, `case_note` and `notification` tables. (`case` is a reserved word in SQL, hence the table's name.)

**Checks**
- 51 API tests pass. The 7 in the new `cases.test.ts` cover:
  - who sees and decides each kind of case, and that the reporter stays private
  - rate limits and duplicate reports
  - the thread and internal notes, and assigning
  - what deciding each kind of case does
  - appeals by the person affected only, decided by another moderator
  - restoring a removed product
  - the notifications
- Type checks pass.
- On a local hub I went through every flow with the website's own forms: report from a product page; the catalog owner's note and decision; assigning, an internal note and a decision as admin; removal by a moderator, the creator's appeal from the audit log, the admin's decision and Restore; a creator's rating review and the decision lowering the rating; a deletion request and its decline; the notifications, the bell's count, and Mark all as read.
- Fixed along the way:
  - The catalog's team could see who reported.
  - A missing Resolve/Decline choice was treated as Decline; now it's refused.
  - The Unassign button sent the typed name instead of nothing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: dhkatz <[email protected]>
2026-11-05 22:14:08 +00:00

19 KiB

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.
  • Draft: a new product before its creator publishes it: only its creator and moderators see it; it can't be got, worn or placed. Drafts never published are deleted after 30 days.
  • Unlisted: a published product left out of search, lists and creator pages for others; anyone with its link can see and get it (membership rules apply).
  • Member: a person a catalog with approval or invite membership has let in. A product is available to a person if a catalog hosting it is approved, not suspended, and open or has them as a member (and hasn't banned them).
  • Imported product: a product a catalog's importer brought from IMVU with its creator's grant (imports.md). imvuProduct is set; otherwise it's like any product.

Who may do what (creators, catalog teams, moderators) is in console.md ("Permissions").

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
t5de-import sub (the job ID), catalog, imvuProduct, imvuRevision 30 min an importer uploads an imported product to its catalog

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

Imports: POST /api/v1/uploads also takes Authorization: Bearer <t5de-import token> (from the catalog's importer). Then notes is optional, and the catalog registers the revision with importToken instead of uploadToken. Validation is the same.

Changes: the catalog polls the hub's changes feed (below) every minute and acts on it: deletes the files of revisions withdrawn or removed (files still used by another revision it hosts stay), and fetches revisions it's asked to mirror.

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, draft}. The upload token must be for this catalog. Without product in the token, a new product is made as a draft (owner: the token's person, name from name; the creator publishes it on the website). 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). Files the hub blocks are refused (409 blocked). The catalog's upload policy (anyone, members, team) is checked when the upload token is made (403 forbidden). With importToken instead of uploadToken: an imported revision (imports.md); the hub checks the job and that a grant still covers it, makes the product (published, owned by the grant's creator) or adds the revision, and marks the job done.
GET /api/v1/catalog/changes?after=<cursor> {changes, cursor}: what the catalog must act on, oldest first: {type: "delete", product, revision, files} (withdrawn, removed by moderation, blocked: delete those files unless another hosted revision uses them), {type: "mirror", product, revision, files, from: [catalog URLs]} (fetch each file from one of from, check its sha256, then confirm below), {type: "status", status} (the catalog was suspended or reinstated). Start with no after; keep the cursor.
POST /api/v1/catalog/hosting {product, revision}: the catalog now hosts a revision it was asked to mirror (it has every file) → 204
POST /api/v1/catalog/stats optional: {day, downloads, bytes} for a past day

For importers (server to server)

Authorization: Importer <catalog ID>:<key> (the key from enabling imports on the catalog; rotated like the catalog's). Only approved catalogs with imports on and not paused.

POST /api/v1/importer/jobs/claim {limit} (1-20) → {jobs: [{id, kind, imvuProduct, lastRevision}]}; claimed jobs not reported within 30 minutes go back to the queue
POST /api/v1/importer/jobs/:id/token {imvuRevision} → {token, url}: a t5de-import token and the catalog's upload URL
POST /api/v1/importer/jobs/:id {status: "done" | "failed" | "unchanged", imvuRevision?, reason?}

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
GET /api/v1/catalogs/:id (anyone, if public or they're a member or on its team) {catalog, membership?: {status}, permissions}
PATCH /api/v1/catalogs/:id (catalog:edit) {name?, url?, contact?, visibility?, membership?, maxRating? ("general"/"adult"), uploads? ("anyone"/"members"/"team"), mirrors? ("auto"/"approve"/"off")}. A new url must answer /health.
POST /api/v1/catalogs/:id/key (catalog:rotate-key) → {key}: a new key; the old one stops working at once
POST /api/v1/catalogs/:id/importer (import:run) {enabled} → {key?}: turns imports on (a new importer key, shown once) or off; POST .../importer/key rotates it; {paused} pauses
GET /api/v1/catalogs/:id/team, PUT/DELETE .../team/:person (team:manage) {role: "operator"}
POST /api/v1/catalogs/:id/transfer (catalog:transfer) {to}: offered to the person, who accepts on their side (POST .../transfer/accept)
POST /api/v1/catalogs/:id/delete (catalog:delete-request) {reason} → {case}: opens a catalog-deletion case (409 requested if one is open); nothing changes until a moderator decides (console.md). Resolved, the catalog's status is deleted: nothing is available from it, and its key stops working
POST /api/v1/catalogs/:id/join join an open catalog, or ask to join an approval one; {invite} to use an invite code. DELETE leaves.
GET /api/v1/catalogs/:id/members?status= (member:approve) members, requests and bans
PUT /api/v1/catalogs/:id/members/:person {status: "member" | "banned"} (member:approve / member:ban); DELETE removes (member:remove)
POST /api/v1/catalogs/:id/invites (member:invite) {maxUses?, expiresIn?} → {invite: {code, url}}; GET lists, DELETE .../invites/:code revokes
GET /api/v1/catalogs/:id/products the products it hosts (its team also sees drafts' counts, delisted ones)
DELETE /api/v1/catalogs/:id/products/:product (hosting:delist) {reason}: the catalog stops hosting every revision of it; it's in the changes feed. The product stays available from other catalogs hosting it.
POST /api/v1/catalogs/:id/suspend, .../reinstate (catalog:suspend) {reason}: a suspended catalog's products aren't available from it, it can't register revisions, and its team sees why
POST /api/v1/products/upload {catalog, product?} → {token, url}: an upload token and the catalog's upload URL (the catalog's upload policy and rating ceiling apply)
GET /api/v1/products?q=&creator=&category=&catalog=&tag=&limit= (anyone) {products}, newest first: published and listed (and the person's own unlisted ones); adult ones only for people with an adult account. category: an IMVU category ID in its category path (its own if chosen, else its latest revision's parent's; e.g. 106 Clothing, 128 Tops). tag: one tag; catalog: those the catalog hosts. limit: 1-200, default 60
GET /api/v1/products/:id (anyone, adult rule as above, except that its creator and moderators always see it; drafts only for their creator and moderators; getting and wearing adult products still need an adult account) {product, revisions, inLibrary?, mine?, available, permissions}
PATCH /api/v1/products/:id (product:edit) {name?, description?, rating?, tags?, listed?, category?, reason?} → {product, fields} (the fields that changed; only those go in the history). Description is HTML, cleaned by the allowlist. Tags: up to 10, lowercased, each up to 24 letters, digits, spaces or dashes. category: the ID of one of its category choices (below), or null for its parent's. Rating may go general → adult; adult → general without product:rate-lower opens a rating-review case for moderators instead (one at a time; its ID is the answer's review; the other fields still change), and no revision that isn't retracted may be rated adult (400 files_adult). reason is required when the person isn't the creator (400 reason_needed), and shown to them. Locked fields need product:edit-locked (403 locked). The creator can't edit a product they deleted; moderators can.
GET /api/v1/products/:id/categories (product:edit) {choices, parent}: the category paths it may be filed under, and its parent's. The choices are every IMVU category path the hub has seen on a revision's parent, and each step of them, under the same top-level category as its parent's (any, if that's unknown). Keeping the top level keeps it the same kind of product: the proxy tells clothing, furniture and rooms apart by category. IMVU's own category tree would replace the seen paths if one turns up.
POST /api/v1/products/:id/publish (product:publish) a draft becomes published (409 published if it is already)
GET /api/v1/products/:id/history (audit:read at the product: its creator, moderators) the info history, newest first: {entries: [{id, at, kind ("edit", "revert", "lock", "publish"), by, byCreator, changes: {field: {from, to}}, reason, revertOf}]}
POST /api/v1/products/:id/history/:entry/revert (product:edit) {reason?}: puts back what an edit (or revert) changed, as a new revert entry, by the same rules as an edit (locks, reasons, ratings); 409 unchanged if the info is already as it was
PUT /api/v1/products/:id/locks (product:lock) {fields, reason}: the fields locked from now on (any of name, description, rating, tags, listed, category; the rest are unlocked). reason required; kept in the history
POST /api/v1/products/:id/transfer (product:transfer) {to}; the other person accepts (.../transfer/accept)
POST /api/v1/products/:id/restore (product:restore) undo a creator's delete
POST /api/v1/products/:id/hosting (hosting:choose) {catalog, revision?}: ask another catalog to mirror it (every revision, or one); the catalog's mirrors setting decides (auto, or its team approves)
POST /api/v1/products/:id/revisions/:n/retract (creator)
DELETE /api/v1/products/:id (creator) deletes it: no longer listed, and nobody new can get it; people who have it keep it (its page, files and library entries stay, and rooms keep showing it). Its creator can't edit it or add revisions after (until restored). 204
POST /api/v1/products/:id/remove (product:remove) {reason}: removes it everywhere: its page and files answer 410 removed, it leaves libraries and rooms, and catalogs delete its files. .../unremove (product:unremove) {reason?} undoes it (for now removal only hides it, so its files and library entries come back). Its creator is told both.
PUT /api/v1/blocks/:sha256 (product:remove) {reason}: blocks a file network-wide: revisions with it can't be registered, and products using it are removed. DELETE unblocks.
GET /api/v1/library {library: [{product, revision, stayOn}]}: the person's products that are available to them, each with the revision to use (products no longer available, e.g. after leaving a catalog, stay in the table but aren't listed until they are again)
PUT /api/v1/library/:id get a product (add it): it must be published and available to the person (403 members_only, with the catalogs they could join)
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.

A product (in lists, its page, the library) is {id, name, description, rating, latest, creator, creatorUsername, parent, image, categories, category, status ("draft"/"published"), listed, tags, locked, deleted, removed, publishedAt, imvuProduct, createdAt, updatedAt} (imvuProduct comes with imports). The product's page also has permissions: what the person may do to it (the website shows actions by them). category is the path chosen for it (null: its parent's), and categories the one it's filed under (category if chosen); parent, image and the parent's categories are the latest revision's: parent is the IMVU product it derives from (0 if none); image (an https address on *.imvu.com, or null) and categories ([{id, name}], outermost first, e.g. Clothing / Clothing for Female / Tops / Coats & Jackets; [] if unknown) are that IMVU product's, from IMVU's public API (GET https://api.imvu.com/product/product-<id>).

Parent info

The hub looks up a revision's parent when the revision is registered (after it's stored; an upload never waits for or fails on it) and stores it with the revision (product_revision.parent_info: {found, image, categories, gender, checkedAt}; found is false when IMVU shows no such product publicly). Info another revision got for the same parent in the last 7 days is reused. Revisions still without it (IMVU was unavailable) are looked up at startup and every 10 minutes, 20 at a time.

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. Seeing needs no membership (design.md, "Seeing versus wearing"): /files answers for any published product the person may see by rating. Wearing does: the hub relays a person's outfit with only the products available to them.
  • Library products appear in the client's inventory (see the proxy's README).

Decorations

T5 furniture and pose products placed in an ordinary IMVU room (design.md, "Decorations over IMVU rooms"). IMVU keeps the room without them; the hub keeps them (room_decoration, routes in api.md), and only T5DE users see them.

  • Saving: when the room's owner saves the room (test.setRoomState) or the client broadcasts a furniture change in it, the proxy takes the hub products out before it goes to IMVU, and PUT /api/v1/decorations/<instance> the room's full list of them ([furniture instance ID, hub product ID, placement]).
  • Loading: when the client loads an IMVU room (test.getRoomState), the proxy GETs its decorations and adds them to the room's contents (as local IDs, fetching the products as for outfits); the hash tells it whether what it has is current. In the room, an incoming furniture broadcast from IMVU gets the decorations added back the same way.
  • Changes: after a PUT, every proxy with a chat in that room gets {"type": "decor", "room": instance} on the realtime connection and GETs again.
  • Viewers only get products they may see: registered, with a revision that isn't retracted, adult ones only for adult accounts. Furniture instance IDs are the proxy's to choose; they must not clash with IMVU's in that room.