Files
t5de-hub/docs/console.md
T
dhkatz ed3a188b82
Release / release (push) Successful in 5s
CI / test (push) Successful in 1m4s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 6s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 21s
docs: permissions, console and imported IMVU products (#6)
Design for the next phases (discussed 2026-11-04):

- `docs/console.md` (new): permissions (`resource:action`), roles as bundles given at a scope (hub, catalog, product, room), one console for everyone who controls something, a tagged audit log scoped by permission, cases (reports, appeals, reviews, catalog deletion), notifications, API tokens, two-factor sign-in for sensitive permissions, "why can't I" and view-as, charts.
- `docs/imports.md` (new): catalogs importing IMVU products with the creator's grant (whole shop or chosen products), admin-recorded grants, requests, unclaimed creators, the job queue, `t5de-importer`, withdrawal, the spike.
- `docs/products.md`: drafts, unlisted products, membership enforced, catalog management routes, moderator edits, info history and locks, file blocks, the catalogs' changes feed, mirroring, the `t5de-import` token and importer routes.
- `docs/design.md`: pointers, phases and the later list.

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

Design for the next phases (discussed 2026-11-04):

- `docs/console.md` (new): permissions (`resource:action`), roles as bundles given at a scope (hub, catalog, product, room), one console for everyone who controls something, a tagged audit log scoped by permission, cases (reports, appeals, reviews, catalog deletion), notifications, API tokens, two-factor sign-in for sensitive permissions, "why can't I" and view-as, charts.
- `docs/imports.md` (new): catalogs importing IMVU products with the creator's grant (whole shop or chosen products), admin-recorded grants, requests, unclaimed creators, the job queue, `t5de-importer`, withdrawal, the spike.
- `docs/products.md`: drafts, unlisted products, membership enforced, catalog management routes, moderator edits, info history and locks, file blocks, the catalogs' changes feed, mirroring, the `t5de-import` token and importer routes.
- `docs/design.md`: pointers, phases and the later list.

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

Co-authored-by: dhkatz <[email protected]>
2026-11-05 13:05:58 +00:00

12 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 and cases about what they control (case:read at a covering scope).
  • Reports are limited per person (10 an hour), one open report per person and resource.
  • 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.

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/POST /api/v1/cases, GET/PATCH /api/v1/cases/:id, POST /api/v1/cases/:id/notes cases
GET /api/v1/notifications, POST /api/v1/notifications/read, GET/PUT /api/v1/notifications/settings notifications
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.