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]>
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 areresource:actionstrings (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:deliston the catalog; removing it everywhere isproduct: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}.tagsare 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:readat 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:readat 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.