| feat(relay): add community reads to the admin API (#8011) The in-app Admin Console needs to work on any community the relay serves, not only the one the desktop is connected to. This adds four read-only routes under `/api/admin/v1` that the stacked desktop PR builds on, puts every admin route under one explicit access rule, and binds every admin request's signature to its real method, which fixes signed `HEAD` on reads. ## Access rule | Mode | Moderation reads | Writes | Staffing (`/operators`) | |---|---|---|---| | `nip98` (default) | signed, any staff role | signed, role-checked | signed Operator | | `disabled` | served to anyone who can reach the relay | `403` | `403` | "Moderation reads" means every read: reports, feedback and attachments, restrictions, and the four community routes below. `nip98` behavior is unchanged, and the Operator API (`/operator/*`) is untouched. **Existing `BUZZ_ADMIN_AUTH=disabled` deployments start serving the four new reads** (community directory, member search, member lookup without staff status, and stored events by ID) with no config change. The startup `WARN`, `docs/admin/README.md`, `.env.example`, `deploy/compose/.env.example` and the `just admin` comment list exactly what disabled mode exposes, say it should be reachable only by people allowed to see all moderation data in every community, and say the whole relay port must be private: the admin API is on the relay's main listener and its hostname check uses the caller's `Host` header. `just admin` binds the relay to `127.0.0.1` unless `BUZZ_ADMIN_AUTH` is exactly `nip98`, so an unset, padded or misspelled value stays local. In code, `authorize()` returns `AdminAccess`, either `NetworkTrusted` (disabled mode, no identity) or `Staff(AdminPrincipal)` (`nip98`, any rostered Operator or Moderator). Each route makes one check: **view** (`authorize()` succeeding), **act** (`AdminAccess::act()`, must be `Staff`, then the existing role checks) or **operator** (`AdminAccess::operator()`, must be an Operator). Act and operator refuse `NetworkTrusted` with `403 this endpoint requires BUZZ_ADMIN_AUTH=nip98`; no principal is ever created for an anonymous caller. `every_route_makes_its_check_in_both_auth_modes` lists every mounted admin route with its check and asserts the result in both modes, and `every_route_is_listed` fails when `router()` mounts a route that list does not name. ## Routes All four live in `api/admin/reads.rs`; `mod.rs` only registers them. Their queries reject unknown fields, like `ReportQuery`. The three `?communityHost=` routes share `CommunityQuery`, which now rejects unknown fields too, so the existing unban, untimeout and direct-action routes that use it do the same; their desktop callers send only `communityHost`. Every admin route that takes `limit` validates it with the module's shared `limit()` helper, so an out-of-range value is `400 invalid_limit`. | Route | Returns | |---|---| | `GET /communities?q=&cursor=&limit=` | `{items: [{id, host, icon\|null}], nextCursor\|null}` | | `GET /members/search?communityHost=&q=&limit=` | `{items: [{pubkey, displayName\|null, nip05\|null, avatarUrl\|null}]}` | | `GET /members/{pubkey}?communityHost=` | `{pubkey, profile: {displayName, nip05, avatarUrl, about}\|null, role: "owner"\|"admin"\|"member"\|null, banned, mutedUntil\|null, isStaff: bool\|null}` | | `GET /events/{id}?communityHost=` | `{id, authorPubkey, kind, content, createdAt, deletedAt\|null, channelId\|null}` | - **One community per read.** Community-scoped routes bind `communityHost` through the tenant binder (`400 unknown_community_host`) and read only that community's `users`, `relay_members`, `community_bans` and `events` rows. - **Directory.** It uses the binder's liveness rule (not archived, not deleted, `deletion_state = 'active'`), so every listed host can be opened. `q` is an escaped, case-insensitive host prefix of at most 255 characters (`400 invalid_query`). Results are ordered and paged by `lower(host)`, which is unique, so the opaque base64url cursor carries only the last host; a malformed cursor is `400 invalid_cursor`. `limit` is 1 to 100 with a default of 50. - **Member search.** It reuses `search_users`, which already escapes, binds and scopes, with no second escaping layer. `q` is trimmed and must be 1 to 100 characters (`400 invalid_query`), and `limit` is 1 to 50 (default 20). It searches community profiles, so former members appear. - **Member lookup.** `isStaff` uses the same roster lookup as the direct-action staff guard and is returned to signed staff only: it is `null` in disabled mode, where the roster is never read. A roster lookup failure is a 500, never "not staff". - **Event preview.** It reads the event by `(community_id, id)` and never returns a private kind (see below). A malformed id, an id stored only in another community and a private kind are all `404 event_not_found`, the same answer as an absent event. ## Private events Admin reads never reveal events the relay keeps private. `admin_hidden_kinds()` combines the four private kind sets in `buzz_core::kind`: `AUTHOR_ONLY_KINDS`, `RESULT_GATED_KINDS`, `P_GATED_KINDS` and `SHARED_GATED_KINDS`. The whole shared-gated set is hidden, including events tagged `["shared","true"]`; the shared tag is never evaluated. Every HTTP response applies it the way it treats a missing event: event preview is `404 event_not_found`, report detail returns the report with `message: null` and `targetAuthorPubkey: null`, and the report list returns a null target author. Moderators can still act on these events, they just cannot read them. Report detail is one query with the hidden-kind list as a parameter, exposed two ways: `admin_get_report` hides private kinds and backs every HTTP response (GET detail, the resolve response, reopen, cancel and the reporter-notice worker), while `admin_get_report_for_enforcement` reads the target unfiltered and is used only by the resolve handler's enforcement load (the staff-target check and ban, timeout and kick target derivation) and by the recovery worker for interrupted actions. Its result is never serialized, so a ban from a report on a private persona bans the real author while GET still shows nothing. ## Method binding Axum serves `HEAD` through `GET` handlers, but the read handlers authorized with a literal `"GET"`, so a correctly signed `HEAD` was rejected. Admin reads now authorize through `authorize_read` and writes through `authorize_write`, both of which use the request's real method and full target, so no handler hardcodes a method string. A database error behind any admin route is now logged before it becomes a 500. ## Directory query plan `idx_communities_host` is on `lower(host)` in the default collation, so the prefix `LIKE` is a filter, not an index condition. The plan walks the index in order and stops once it has `limit` matches. A common prefix is cheap (on local Postgres 16 with 21,359 communities, a first page skipped 2,728 index rows in 3.9 ms), but a prefix with few or no matches walks every index entry, so its cost grows with the number of communities. A `COLLATE "C"` index would turn the prefix into an index range; it is left out because the admin directory is low-traffic and the cost is small at current scale. The cursor condition is `lower(host) > COALESCE($2, '')`, with no `OR`, so it remains an index condition under a generic plan: ```text -> Bitmap Index Scan on idx_communities_host Index Cond: (lower((host)::text) > COALESCE($2, ''::text)) ``` --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> | 6 天前 |