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

258 lines
17 KiB
Markdown

# 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 <name>".
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.