Files
t5de-hub/docs/console.md
T
dhkatz 7299e10b08
Release / release (push) Successful in 3s
CI / test (push) Successful in 17m26s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 6s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 15s
feat: cases (reports, reviews, requests, appeals) and notifications (#9)
Phase 3, part 1 (`docs/design.md`, "Next"): cases and notifications. File blocks, API tokens and two-factor sign-in come as separate PRs.

**Cases**
- One queue (Console → Cases) for everything someone has to decide. Each case has:
  - a thread of notes, where internal notes are visible only to those deciding
  - an assignee (moderators assign cases to moderators)
  - a decision, Resolve or Decline, with a written resolution that the person who opened it sees
- **Reports:** anyone signed in can report a product or catalog from its page (the API also takes rooms and people).
  - Limits: 10 an hour, one open report per person and thing, and you can't report yourself.
  - Reports about a product go to the teams of the catalogs hosting it, as well as to moderators.
  - Only the reporter and moderators know who reported. The audit entry for a report isn't tagged with what it's about, so a product's creator can't find out from their audit log either.
- **Rating reviews:** a creator lowering a rating from adult to general now opens a review instead of being refused. The rest of the edit still saves. Resolving the review lowers the rating, recorded in the product's history.
- **Removal requests:** a catalog's team can ask moderators to remove a product it hosts, everywhere. Resolving it removes the product.
- **Deletion requests:** a catalog's owner asks for deletion from the catalog's console page, and a moderator decides, as agreed. Resolving it marks the catalog `deleted`: its key stops working, nothing is available from it, and its changes feed says so.
- **Appeals:**
  - From the audit log, the person an action affected can appeal it, once: their product removed, delisted or edited by someone else; their catalog suspended or rejected; a ban from a catalog or a declined request to join.
  - Another moderator decides. An admin may decide an appeal of their own action, and the audit log flags it.
  - Resolving records the decision; the moderator then undoes the action where it's done. For that, there's now a Restore for removed products (Console → Products, for moderators); the design mentioned one, but it was never built.
- The console shows a "Cases" tab, and a to-do with the number of open cases the person could decide.

**Notifications**
- A bell in the header with the unread count, and a notifications page with "Mark all as read".
- Each is also sent to the person's proxies over the realtime connection (`{"type": "notification"}`), so the client can show them later.
- They cover:
  - a moderator edited or locked your product, or undid a change (with the reason)
  - your product was removed or restored
  - your case was decided
  - someone replied on your case
  - a catalog decided your membership

**Also changed**
- A product's creator and moderators can now see it when it's adult, even without an adult account. Before, a creator who made their own product adult couldn't open its page any more, and neither could moderators. Getting and wearing adult products still need an adult account.

**Not done yet** (in `docs/console.md`): email notifications (the hub sends no email yet), canned reasons, private moderator notes on people and products, and second approval for the most destructive actions.

**Migration** `0012_cases`: the `moderation_case`, `case_note` and `notification` tables. (`case` is a reserved word in SQL, hence the table's name.)

**Checks**
- 51 API tests pass. The 7 in the new `cases.test.ts` cover:
  - who sees and decides each kind of case, and that the reporter stays private
  - rate limits and duplicate reports
  - the thread and internal notes, and assigning
  - what deciding each kind of case does
  - appeals by the person affected only, decided by another moderator
  - restoring a removed product
  - the notifications
- Type checks pass.
- On a local hub I went through every flow with the website's own forms: report from a product page; the catalog owner's note and decision; assigning, an internal note and a decision as admin; removal by a moderator, the creator's appeal from the audit log, the admin's decision and Restore; a creator's rating review and the decision lowering the rating; a deletion request and its decline; the notifications, the bell's count, and Mark all as read.
- Fixed along the way:
  - The catalog's team could see who reported.
  - A missing Resolve/Decline choice was treated as Decline; now it's refused.
  - The Unassign button sent the typed name instead of nothing.

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

Phase 3, part 1 (`docs/design.md`, "Next"): cases and notifications. File blocks, API tokens and two-factor sign-in come as separate PRs.

**Cases**
- One queue (Console → Cases) for everything someone has to decide. Each case has:
  - a thread of notes, where internal notes are visible only to those deciding
  - an assignee (moderators assign cases to moderators)
  - a decision, Resolve or Decline, with a written resolution that the person who opened it sees
- **Reports:** anyone signed in can report a product or catalog from its page (the API also takes rooms and people).
  - Limits: 10 an hour, one open report per person and thing, and you can't report yourself.
  - Reports about a product go to the teams of the catalogs hosting it, as well as to moderators.
  - Only the reporter and moderators know who reported. The audit entry for a report isn't tagged with what it's about, so a product's creator can't find out from their audit log either.
- **Rating reviews:** a creator lowering a rating from adult to general now opens a review instead of being refused. The rest of the edit still saves. Resolving the review lowers the rating, recorded in the product's history.
- **Removal requests:** a catalog's team can ask moderators to remove a product it hosts, everywhere. Resolving it removes the product.
- **Deletion requests:** a catalog's owner asks for deletion from the catalog's console page, and a moderator decides, as agreed. Resolving it marks the catalog `deleted`: its key stops working, nothing is available from it, and its changes feed says so.
- **Appeals:**
  - From the audit log, the person an action affected can appeal it, once: their product removed, delisted or edited by someone else; their catalog suspended or rejected; a ban from a catalog or a declined request to join.
  - Another moderator decides. An admin may decide an appeal of their own action, and the audit log flags it.
  - Resolving records the decision; the moderator then undoes the action where it's done. For that, there's now a Restore for removed products (Console → Products, for moderators); the design mentioned one, but it was never built.
- The console shows a "Cases" tab, and a to-do with the number of open cases the person could decide.

**Notifications**
- A bell in the header with the unread count, and a notifications page with "Mark all as read".
- Each is also sent to the person's proxies over the realtime connection (`{"type": "notification"}`), so the client can show them later.
- They cover:
  - a moderator edited or locked your product, or undid a change (with the reason)
  - your product was removed or restored
  - your case was decided
  - someone replied on your case
  - a catalog decided your membership

**Also changed**
- A product's creator and moderators can now see it when it's adult, even without an adult account. Before, a creator who made their own product adult couldn't open its page any more, and neither could moderators. Getting and wearing adult products still need an adult account.

**Not done yet** (in `docs/console.md`): email notifications (the hub sends no email yet), canned reasons, private moderator notes on people and products, and second approval for the most destructive actions.

**Migration** `0012_cases`: the `moderation_case`, `case_note` and `notification` tables. (`case` is a reserved word in SQL, hence the table's name.)

**Checks**
- 51 API tests pass. The 7 in the new `cases.test.ts` cover:
  - who sees and decides each kind of case, and that the reporter stays private
  - rate limits and duplicate reports
  - the thread and internal notes, and assigning
  - what deciding each kind of case does
  - appeals by the person affected only, decided by another moderator
  - restoring a removed product
  - the notifications
- Type checks pass.
- On a local hub I went through every flow with the website's own forms: report from a product page; the catalog owner's note and decision; assigning, an internal note and a decision as admin; removal by a moderator, the creator's appeal from the audit log, the admin's decision and Restore; a creator's rating review and the decision lowering the rating; a deletion request and its decline; the notifications, the bell's count, and Mark all as read.
- Fixed along the way:
  - The catalog's team could see who reported.
  - A missing Resolve/Decline choice was treated as Decline; now it's refused.
  - The Unassign button sent the typed name instead of nothing.

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

Co-authored-by: dhkatz <[email protected]>
2026-11-05 22:14:08 +00:00

238 lines
15 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.
- **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>".
```
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.