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]>
134 lines
11 KiB
Markdown
134 lines
11 KiB
Markdown
# 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 `GET`s 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}` |
|