Files
t5de-hub/docs/console.md
T
dhkatzandClaude Opus 5.5 2b88a52bcb
Pull request / title (pull_request) Successful in 0s
CI / test (pull_request) Successful in 1m9s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped
feat: personal API tokens
Tokens for scripts, made, listed and revoked on the account page. Each grants some of the person's own permissions
at chosen places (a product of theirs, a catalog whose team they're on, or the hub for moderators); every check is
the intersection with what they hold now. Tokens work only on routes that check a permission (session-only routes
answer token_not_allowed), are shown once and stored as hashes, note their last use, and mark the audit entries of
what they do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:58:58 -08:00

17 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.
  • Blocks: those who remove products hub-wide (product:remove): blocked files, a file's page (the products using it; block, unblock). Each file in a product's revisions links to its page for them.
  • 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 ".

Built (2026-11-05):

  • Made, listed and revoked on the account page (a website session only: a token can't make tokens). Each grant is a permission the person holds now at the hub (moderators and admins), a catalog whose team they're on, or a product of theirs (400 not_held otherwise). At most 20 active tokens, 50 grants, 365 days.
  • Usable on the routes that check a permission (product info, publish, locks, history; uploading new revisions of a product, revision:upload, and retracting them; a catalog's settings, key, team, members, invites and hosted products; blocks; assigning cases). Other routes refuse tokens (403 token_not_allowed), including making a new product, reports and the console. On those routes every check is the intersection, so a moderator's token granted product:remove hub-wide can block files but not suspend catalogs, and losing the role takes it from the token.
  • The audit log marks actions by token (detail.token: its name); making and revoking one is audited (token.create, token.revoke).
GET /api/v1/account/tokens {tokens: [{id, name, grants: [{permission, scope, name}], status (active, expired, revoked), createdAt, expiresAt, lastUsedAt, revokedAt}], scopes: [{scope, name, permissions}], maxDays}: scopes is what a token could be given
POST /api/v1/account/tokens {name, days, grants: [{permission, scope: {type: "hub"} or {type: "catalog" or "product", id}}]} → 201 {token, info}; token is shown only here
DELETE /api/v1/account/tokens/:id revokes it → 204
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 (above)
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.