Files
t5de-hub/docs/console.md
T
dhkatzandClaude Opus 5.5 8af4b7b6a9
Pull request / title (pull_request) Successful in 1s
CI / test (pull_request) Successful in 1m5s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped
feat: cases (reports, rating reviews, removal and deletion requests, appeals) and notifications
Cases are one queue for what someone has to decide, each with a thread (internal notes for those deciding), an
assignee and a decision. Reports about a product go to the teams of the catalogs hosting it and to moderators, and
only the reporter and moderators know who reported. A creator lowering a rating opens a rating review; a catalog's
owner asking to delete it opens a deletion request; a catalog's team can ask for a product's removal everywhere; the
person an action affected can appeal it once, decided by another moderator (an admin may decide their own, flagged).
Resolving carries the case out: the rating is lowered, the product removed, the catalog deleted.

Notifications reach people on the website (a bell with the unread count) and their proxies (realtime): moderators'
edits to their products, removals and restores, decisions and replies on their cases, membership decisions.

Also: moderators can restore a removed product, and a product's creator and moderators can see it when it's adult
without an adult account (getting and wearing it still need one).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 07:03:59 -08:00

15 KiB

Permissions, the console, audit and stats

How people manage what they control on the hub: products, catalogs, importers, hosted rooms, and the hub itself. The products contract is products.md, imports are imports.md; this is who may do what, and what they see.

Permissions (Decided, 2026-11-04)

  • Code checks permissions, never roles. Every check is can(person, permission, target). Permissions are resource:action strings (list below).
  • A role is a named bundle of permissions, given to a person at a scope: the hub (everything), a catalog, a product or a hosted room.
  • Broader scopes cover narrower ones. A permission held hub-wide covers every catalog, product and room. One held on a catalog covers that catalog's members, importer, grants and hosting (not the products themselves: delisting a product from the catalog is hosting:delist on the catalog; removing it everywhere is product:remove, which only hub-wide roles have).
  • Owners are derived, not stored as assignments: a product's creator (product.creator_id), a catalog's owner (catalog.owner_id), a hosted room's owner. They can't fall out of step with those columns; they change only by transfer.
  • Giving roles: you may give (or take away) a role at a scope only if you hold every permission in it at that scope, so nobody hands out more than they have. Built-in roles can't be deleted or changed (except by admins, hub-wide). Every change is audited.
  • Custom roles (later): the model allows them (a catalog owner making "Member approver" with only member:approve); built-in roles come first.
role(id, name, scope_type, permissions[], builtin)
role_assignment(person, role, scope_type, scope_id)   -- scope_id null for the hub

Built-in roles

Role Scope Permissions
Admin hub all
Moderator hub product:* (not transfer), revision:retract, catalog:suspend, catalog:delete-review, catalog:approve, hosting:delist, import:grant-on-behalf, import:withdraw, import:pause, case:*, person:approve, person:ban, person:link-cap, person:notes, audit:read, stats:read, room:*
Catalog owner (derived) catalog catalog:edit, catalog:rotate-key, catalog:delete-request, catalog:transfer, team:manage, member:*, hosting:delist, import:* (not grant-on-behalf), case:read, case:resolve, audit:read, stats:read
Catalog operator catalog as the owner, without catalog:delete-request, catalog:transfer, team:manage
Creator (derived) product product:edit, product:publish, product:delete, product:restore, product:transfer, revision:upload, revision:retract, hosting:choose, case:read, audit:read, stats:read
Room owner (derived) hosted room room:*
Room moderator hosted room room:kick, room:ban, room:mute

Hub-wide admin and moderator keep living in Better Auth's user.role (its admin plugin needs it); the permission check maps them to the bundles above. A creator with product:edit can't change fields a moderator locked (product:edit-locked), and lowering a rating is product:rate-lower (moderators only; creators ask for a review).

Permissions

Resource Actions
product edit, edit-locked, lock, rate-lower, publish, delete, restore, remove, unremove, transfer
revision upload, retract
catalog approve, edit, rotate-key, suspend, delete-request, delete-review, transfer
team manage
member approve, invite, remove, ban
hosting choose, delist
import grant, grant-on-behalf, accept, run, pause, withdraw
case read, resolve, assign
audit read, read-sensitive
stats read
person approve, ban, link-cap, notes, role, view-as
room edit, close, kick, ban, mute
settings edit

audit:read-sensitive (IP addresses, sessions), person:role, person:view-as and settings:edit are admins'.

"Why can't I?"

A refused action answers 403 forbidden with missing: {permission, scope} (e.g. {permission: "member:approve", scope: {catalog: "c1"}}), and the website says which permission it needed. View as (admins, person:view-as): the console as another person sees it, read-only, audited, with a banner; nothing can be changed while viewing.

Two-factor sign-in

Anyone holding a sensitive permission must have two-factor sign-in on (Better Auth's twoFactor plugin, TOTP with backup codes): product:remove, person:ban, person:role, audit:read-sensitive, catalog:transfer, catalog:suspend, import:grant-on-behalf, settings:edit. Without it, those actions answer 403 two_factor_required and the console asks them to set it up. Giving someone such a role reminds them.

The console

One console at /console for everyone who controls something: a creator with a product, a catalog's team, a hosted room's owner or moderators, moderators and admins. Others don't see the link. Every page and action asks for a permission, and the API checks it again (the website only hides what can't be used).

Sections (each shown only to those with something in it):

  • Overview: charts and to-dos (open cases, member requests, failed imports, drafts) for what the person controls.
  • Products: their products (everyone's for moderators), drafts, info history, locks, stats.
  • Catalogs: the catalogs they're on the team of (all for admins and moderators): settings, key, team, members and invites, hosted products, cases, the importer (grants, queue, requests) when it has one, health and storage.
  • Rooms: their hosted rooms.
  • Cases: reports, appeals and reviews about what they control.
  • People: moderators and admins.
  • Audit: scoped (below).
  • Settings: admins (registration, announcements, roles).

No catalog is special: importer pages are part of any catalog with imports on; what only admins do (granting on a creator's behalf) is a hub-wide permission usable on every such catalog.

Audit log

  • Every change is an entry: {at, actor, action, tags, detail}. tags are the resources it touches, e.g. {catalog: "c1", product: "p9"} for "operator delisted p9 from c1", {person: "x"} for a ban.
  • Who sees an entry: anyone with audit:read at a scope covering any of its tags. Admins and moderators see everything.
  • Fields by permission: IP addresses and sessions only with audit:read-sensitive. People appear by hub username; linked IMVU accounts appear only to moderators (alts stay private). Catalog owners see moderators' actions on their catalog and their products, with the moderator's name and reason.
  • Filters: resource, actor, action, date range, text; admins also get "everything a person did" and "everything that happened to X". Every detail page links to its entries.
  • Retention (later): entries kept indefinitely for now; a rule per action type later.
audit(id, at, actor, action, tags jsonb, detail jsonb, ip)   -- GIN index on tags

The existing target column becomes tags (target kept for old entries).

Cases: reports, appeals and reviews

One queue for everything someone has to decide. A case has a kind, the resources it's about (tags, as the audit log), who opened it, a status (open, resolved, declined), an assignee, a resolution and a thread of notes.

Kind Opened by Decided by
report anyone signed in, about a product, catalog, room or person the catalog's team (for products it hosts) and moderators
rating-review a creator lowering adult to general moderators (product:rate-lower)
removal-request a catalog's team asking to remove a product everywhere moderators
catalog-deletion a catalog's owner (deleting goes to review for now) moderators (catalog:delete-review)
appeal the person affected by a removal, ban, delisting or decline (one per action) a different moderator than the one who acted
  • People see cases they opened or are assigned, and cases about what they control (case:read at a covering scope), except reports: those only the reporter, the hosting catalogs' teams (reports about products) and moderators see, and only the reporter and moderators see who reported (a report's audit entry isn't tagged with what it's about).
  • Who decides: moderators, by the kind's permission (case:resolve; product:rate-lower for rating reviews, product:remove for removal requests, catalog:delete-review for deletions); reports about a product also the teams of the catalogs hosting it (case:resolve there). An appeal goes to another moderator than the one who acted; an admin may decide one of their own, which the audit log flags (ownAction).
  • Resolving a rating review lowers the rating (as an edit, in the product's history); a removal request removes the product; a deletion request deletes the catalog (status deleted; its changes feed says so). Reports and appeals record the decision, and the moderator acts where it's done: a removed product is restored (POST /api/v1/products/:id/unremove), an edit undone from the history, a catalog reinstated, a member unbanned.
  • Appealable actions: product.remove, product.edit/lock/revert by others (the creator), catalog.delist (the product's creator), catalog.suspend/reject (the owner), catalog.member_ban/member_decline (the person). The audit log marks the entries a person may appeal (appealable).
  • Reports are limited per person (10 an hour), one open report per person and resource.
  • Not built yet: canned reasons, moderator notes and second approval (below).
  • Canned reasons (admins edit the list) for removals, bans, declines and edits; free text can be added.
  • Moderator notes: private notes on people and products (person:notes), shown in the console only.
  • Second approval for the most destructive actions: banning a person, removing a catalog and transferring a catalog need a second moderator's approval (a case the first opens); admins can act alone in an emergency, which is flagged in the audit log.

Notifications

One table; shown on the website (a bell, unread count), on the proxy's realtime connection ({"type": "notification"}, so the client can show them later), and by email for types the person opts into.

Built (2026-11-05): product_edited (by someone other than the creator, with the reason), product_removed, product_restored, case_decided, case_note, membership_decided. Not yet: email (the hub sends none yet) and its settings, and the import, grant, new-revision and request-digest types.

Types: a moderator edited your product (with the reason); a case about you or yours was opened or decided; your membership request was decided; your import finished or failed; your catalog's deletion request was decided; a product in your library has a new revision; a grant was made or withdrawn; someone requested your products (daily digest).

notification(id, person, type, tags, data, created_at, read_at)
notification_setting(person, type, email boolean)

API tokens

Personal tokens for scripts: a name, an expiry (at most a year), and a subset of the person's own permissions at chosen scopes (e.g. revision:upload on two products, member:approve on a catalog). Sent as Authorization: Bearer t5de_pat_<random>; only the hash is stored; shown once. A token never has more than its owner holds now: the check is the intersection, so losing a role takes it from tokens too. Listed and revocable in the account page; every use updates last_used_at; actions are audited as the person, marked "by token ".

api_token(id, person, name, hash, grants jsonb, expires_at, last_used_at, created_at, revoked_at)

Stats and charts

Charts in the console (shadcn/ui charts, Recharts), following the website's look: neutral, light and dark, color only for meaning.

Who Charts
everyone activity on their resources over time, open cases
creators library adds per product per day, people wearing them this week, import requests
catalog teams members over time, uploads per day, products by rating, storage, cases opened and resolved, importer runs and failures
admins sign-ups and approvals, proxies online, products and revisions per day, library adds, cases, catalogs by status, age checks against the provider's monthly budget
  • A nightly job rolls counts up into stat_daily(scope_type, scope_id, metric, day, value); "now" figures are live counters. Charts never scan the raw tables.
  • Only totals. Counts of people (wearers, adds, members) under 5 show as "fewer than 5", so creators can't pick out individuals.
  • Catalogs may report daily download totals (POST /api/v1/catalog/stats); optional, so other people's catalogs don't have to.
  • Client usage stays in Aptabase; these are the hub's own data.

Routes

/api/v1/console/* (signed in; each checks its permission). Lists take limit and a cursor.

GET /api/v1/console {sections, counts, todo}: what this person may see
GET /api/v1/console/permissions?scope=... the person's permissions at a scope (the website uses it to show actions)
GET /api/v1/console/audit?tag=&actor=&action=&from=&to=&q= entries the person may see, newest first
GET /api/v1/console/stats?scope=&metric=&from=&to= {series: [{day, value}]}
GET /api/v1/cases?status=&kind=&mine=1&before=&limit= {cases, before, moderator}: the cases the person may see (below), newest first
POST /api/v1/cases {kind, about?, catalog?, audit?, reason} → 201 {case}. report: about names one product, catalog, room (its number) or person (username); 10 an hour (429 rate_limited), one open per person and resource (409 already_reported), not yourself. removal-request: about.product, hosted by catalog, whose hosting:delist the person needs. appeal: audit, an entry of an action that affected the person (409 appealed if they appealed it already)
GET /api/v1/cases/:id {case, notes, canDecide, canAssign}; internal notes only for those deciding and moderators
POST /api/v1/cases/:id/notes {body, internal?}: a note; the opener's reach the assignee and others' reach the opener as notifications (internal ones reach nobody)
POST /api/v1/cases/:id/assign (case:assign) {to: username or null}: a moderator
POST /api/v1/cases/:id/decide {decision: "resolve" or "decline", resolution}: by those who may decide it (below); resolving carries out a rating review, removal request or deletion request; the opener is told
GET /api/v1/notifications?before= {notifications: [{id, type, tags, data, at, read}], unread, before}, newest first, 50 a page
POST /api/v1/notifications/read {ids?} (all if left out) → {unread}
GET/POST /api/v1/account/tokens, DELETE /api/v1/account/tokens/:id API tokens
GET/PUT/DELETE /api/v1/roles/:scopeType/:scopeId/:person role assignments at a scope
POST /api/v1/console/view-as/:person (admins) start viewing as someone, read-only

Later

  • Custom roles and their editor.
  • Time-limited role assignments (a temporary moderator for an event).
  • Announcement banner and maintenance mode (admins).
  • Retention rules for the audit log and soft-deleted records.
  • Webhooks (e.g. to Discord) for a catalog's cases and uploads.
  • Health alerts for catalog teams when the hub can't reach their catalog; storage quotas.
  • CSV export of stats.