Files
t5de-hub/docs/api.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

11 KiB

The hub's API

apps/api (Elysia on Bun). The route definitions in apps/api/src/app.ts are the reference: their schemas validate requests and type the website's client (Eden). This is the overview.

  • /api/auth/*: Better Auth (accounts, sessions, device sign-in, JWT keys).
  • /api/v1/*: the hub's own routes.

The website reaches the API through its own origin (Next.js rewrites /api to it), so sessions are first-party cookies. T5DE's proxy calls it directly with Authorization: Bearer <session token>.

Errors are {"error": "<code>", "message": "<text for people>"} with a 4xx/5xx status; message can be shown as is. Codes: invalid, signed_out, forbidden, not_found, no_such_imvu_account, already_linked, code_missing, expired, link_cap, rate_limited, imvu_unavailable, unknown_product, hosted_room.

Accounts (Better Auth)

POST /api/auth/sign-up/email {email, password, name, username}. Usernames 3-32 of A-Za-z0-9_; passwords 10-256 characters. The first account is an admin. With registration closed, the others wait for approval (approved: false): every route but /me answers 403 awaiting_approval, and the rest of the hub treats them as signed out.
POST /api/auth/sign-in/username {username, password}
POST /api/auth/sign-out
GET /api/v1/me {person} (approved false while waiting), with picture: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or null, console: whether the console is for them, and unread: their unread notifications
GET /api/v1/hub (anyone) {registration}: open or closed

Proxies

POST /api/auth/device/code {client_id: "t5de-proxy"} → {device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in} (RFC 8628). verification_uri is the website's /device.
POST /api/auth/device/token {grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code, client_id} → {access_token} (a session token, used as a bearer token) or {error: authorization_pending | slow_down | expired_token | access_denied}
POST /api/v1/device/approve (website, signed in) {code}: signs the waiting proxy in as this person
POST /api/v1/badges (bearer or website session) {imvuIds} (at most 200 IMVU account IDs) → {badges}: those linked to a T5DE account that turned on showBadge for them and isn't banned. Nothing else about them. 120 a minute per person.
GET /api/v1/realtime WebSocket, bearer token: presence, outfits, the room channel, decoration notices (decor) and hosted rooms (room.*, docs/hosted-rooms.md). Protocol in apps/api/src/realtime.ts.
GET /.well-known/jwks.json the Ed25519 keys JWTs are signed with

Website routes (signed in unless noted)

GET /api/v1/home (anyone) {onlineCount, links?}
GET /api/v1/online (anyone) {online}: accounts showing they're online (moderators see all, with hidden)
GET /api/v1/online/events (anyone) server-sent events: online with {online} now and on each change
GET /api/v1/rooms (anyone) {rooms}: listed IMVU rooms of T5DE users, with the T5DE users in each now (AP rooms for adults only)
GET /api/v1/account {person, links, challenges, computers, canLink}
POST /api/v1/account/links {username} → 201 {challenge} (the tagline code)
POST /api/v1/account/links/:imvu/check {link, moved}
DELETE /api/v1/account/challenges/:imvu cancel linking
PATCH /api/v1/account/links/:imvu {showBadge?, showPresence?, listRooms?} → {link} (each off by default; showBadge: others see the T5 badge on this account in the client)
POST /api/v1/account/links/:imvu/recheck {link} (age checked with IMVU again)
DELETE /api/v1/account/links/:imvu unlink
POST /api/v1/account/signout-computers signs T5DE out on all their computers

Hosted rooms

Rooms the hub hosts (docs/hosted-rooms.md, which has the details and the realtime part). Bearer or website session; the GETs also signed out.

POST /api/v1/hosted-rooms {name, description?, roomPid, rating, maxUsers?, imvuId, privacy?} → 201 {room}. imvuId: one of the person's linked accounts, shown as owner. Name 1-64 characters, description up to 512 (plain text), maxUsers 2-30 (default 10), rating general or adult (adult people only). Up to 10 open rooms a person (room_cap), 20 new ones an hour.
GET /api/v1/hosted-rooms {rooms}: open rooms, newest first (at most 200): public ones, and private ones the person owns or moderates; adult ones for adults only. ?mine=1: the person's own; ?favorites=1: their favorites; ?recent=1: the last 20 they joined.
GET /api/v1/hosted-rooms/:n {room, scene: {contents, hash}, myRating}; 404, or 410 closed
PATCH /api/v1/hosted-rooms/:n (owner) {name?, description?, rating?, maxUsers?, roomPid?, privacy?, moderators?} → {room}. Made adult: non-adults in it are sent out.
PUT /api/v1/hosted-rooms/:n/scene (owner) {contents, roomPid?} → {room, scene}. contents: up to 500 [furniture ID, product ID (integer or hub ID), placement (up to 200 characters)]. 60 a minute.
DELETE /api/v1/hosted-rooms/:n (owner or moderator) closes it for good; everyone in it gets room.closed
GET .../:n/participants, GET .../:n/bans, DELETE .../:n/bans/:imvu, GET/PUT/DELETE .../:n/image, PUT/DELETE .../:n/favorite, PUT/DELETE .../:n/rating Phase 2: docs/hosted-rooms.md, "Phase 2"

A room is {n, instanceId, chatId, name, description, roomPid, rating, maxUsers, ownerImvuId, ownerName, occupancy, privacy, moderators, moderatorNames, image, favorite, score, ratings, createdAt}; instanceId is <owner IMVU ID>-<2100000000 + n>, chatId 2100000000 + n, occupancy the people in it now.

Decorations

T5 products placed in ordinary IMVU rooms (docs/products.md, "Decorations"). instance is IMVU's room instance, <owner's customer ID>-<n> (^[1-9][0-9]{0,11}-[1-9][0-9]{0,9}$); hosted rooms' (room number 2100000000 and up) are refused (400 hosted_room: they have their own scene). A room's decorations are {instance, contents, hash}: contents is a list of [furniture instance ID (integer), hub product ID, placement (up to 200 characters)], hash the sha256 (hex) of contents as JSON.

GET /api/v1/decorations/:instance (anyone, also signed out) the room's decorations as the viewer sees them: only products with a revision that isn't retracted, adult ones only for people with an adult account. A room without any: contents: [].
GET /api/v1/decorations?instances=a,b (anyone) {rooms: [{instance, contents, hash}]}, up to 20 rooms, in the order asked
PUT /api/v1/decorations/:instance (bearer or website session) {contents} → the room's decorations as the person sees them. Only if one of the person's linked IMVU accounts is the room's owner (else 403 forbidden). Up to 300 items, each furniture ID once; every product must be registered (400 unknown_product). An empty list removes them. Everyone with a chat in the room gets {"type": "decor", "room": instance} on the realtime connection. 60 a minute.

Products, catalogs and libraries

The routes and tokens are in docs/products.md. Products are {id, name, description, rating, latest, creator, creatorUsername, parent, image, categories, category, status, listed, tags, locked, deleted, removed, publishedAt, createdAt, updatedAt}; parent and image come from the latest revision (its IMVU parent's public info), and categories too unless a category was chosen for it (category). GET /api/v1/products takes q, creator, category (an IMVU category ID), tag, catalog and limit (1-200, default 60).

Age verification

Vendor-neutral (docs/design.md, "Age"): IMVU's answer is recorded when an account is linked or checked again; person-level providers (phone wallets, document checks) are started here.

GET /api/v1/age {providers: [{id, label, vendor, scope}], checks}: the providers in use and the person's own checks (newest first; status pending, complete, failed or revoked)
POST /api/v1/age/:provider/start {returnURL} (a path on the website, or an address on it) → {kind: "done"}, {kind: "redirect", url, ref} (send the person there) or {kind: "browser", request, ref} (hand request to the browser's Digital Credentials API). An unfinished check is given again. 409 refused: already verified, or a limit reached (message says which); 502 provider_unavailable
POST /api/v1/age/:provider/complete {ref, response}: the browser's answer, for wallet providers
POST /api/v1/age/webhook/:provider the provider's webhook; the provider checks its signature (401 if it isn't the provider's). A later answer replaces an earlier one (an adult answer taken back is revoked)

Console

Permissions, roles and scopes are in docs/console.md. A missing permission answers 403 forbidden with missing: {permission, scope}. Catalog management routes are in docs/products.md.

GET /api/v1/console (anyone controlling a catalog, product or hosted room, and moderators; else 403) {sections, mine, todo, moderation, audit}: todo counts what waits on them (cases they could decide, drafts, memberRequests; for moderators pendingCatalogs, waitingPeople), moderation ({counts, connections, online}) only for moderators, audit the latest 20 entries they may see
GET /api/v1/console/audit?catalog=&product=&room=&person=&actor=&action=&q=&from=&to=&before=&limit= {entries, before}: entries the person may see (docs/console.md), newest first; action matches a prefix, from/to are days, before an entry ID for the next page. Entries are {id, at, actor, action, target, tags, tagNames, detail}
GET /api/v1/console/permissions?catalog=&product= {roles, permissions} at that scope (the hub if neither)
GET /api/v1/console/catalogs {catalogs, moderator}: every catalog for moderators, else those on whose team the person is
GET /api/v1/console/products?q=&status=&all=1 {products, moderator, all}: the person's own products (with all=1, moderators get everyone's), drafts, unlisted, deleted and removed ones included, most recently changed first; each with libraries (how many people have it). status: draft, published (and listed), unlisted, locked, deleted or removed; q matches the name, ID or creator. 403 for people with no products

Moderators:

GET /api/v1/console/people?q=&waiting=true {people} (waiting: only accounts waiting for approval)
POST /api/v1/console/people/:id/approve {person}
POST /api/v1/console/people/:id/decline an account waiting for approval: deletes it
GET /api/v1/console/settings, PUT (admins) {registration}: open or closed (default: the API's REGISTRATION, else open)
GET /api/v1/console/people/:id {person, links, computers}
POST /api/v1/console/people/:id/link-cap {linkCap} (0-1000)
POST /api/v1/console/people/:id/ban {reason}; only admins ban moderators
POST /api/v1/console/people/:id/unban
POST /api/v1/console/people/:id/role (admins) {role}