mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat(desktop): browse and moderate every community in the Admin Console (#8027)
The Admin Console only ever saw the community the desktop happened to be connected to. With #8011's community reads, the console can now browse every community on the relay and, for signed staff, moderate any of them by name without switching connections. 🤖 ## What changes - **Communities tab.** Lists every live community on the relay, searchable by host prefix with "Load more" paging. The connected community is pinned at the top by exact host match. Report rows get a community badge that opens that community. - **Community page.** Reports, Restrictions, Members and Actions, each scoped to the community on the page. The Actions form has no host field: it always acts on the page's community, and the TypeScript host normalization is gone from the Actions tab. - **Native reads** (`admin_list_communities`, `admin_search_members`, `admin_get_member`, `admin_get_event`). Every request validates the explicit community host and NIP-98-signs the real method and URL. Failures come back as a structured error (`status`, `bodyComplete`, `bodyEmpty`, `code`), so the UI can tell an older relay's empty 404, a coded `event_not_found` and a transport failure apart, and never says "not found" unless the relay did. - **Explicit-host lifts.** `admin_lift_restriction` sends the page's community host, not the active relay's. It freezes the relay and signer the intent was reviewed under, and refuses to send if either has changed. - **Pending actions survive navigation.** A panel-level `DirectActionsProvider` holds the frozen intent, so switching communities or tabs mid-confirm doesn't lose it. Only Discard drops it. A signer change remounts the controller and drops it. - **Fenced lookups.** Member and event lookups are keyed by origin, signer, community, target and a generation counter, so a late answer for one community can't unlock Review in another. - **Shared member picker** (`MemberSearchResult`, also used by Add Member), showing the truncated npub so members with the same name can be told apart. Delete accepts a note link and previews the message before Review. Relay staff are blocked before Review. - **Disabled admin auth is read-only.** With `BUZZ_ADMIN_AUTH=disabled` the Communities tab and community pages still render, and report badges still open them, but every ban, timeout, delete, add-member and lift control is disabled. The settings badge says the console is read-only and that acting needs `nip98`. Staffing stays hidden. - #7904's protections in the Actions tab are kept: frozen intent, `requestId`, Retry, `notSent` and the key-backup guard. - **Readable load errors.** Admin list and detail loads show the relay's error message (for example "request is not authorized"), never the raw JSON envelope. - **Failed kicks reload the report.** A resolve that fails with `enforcement_failed` reloads the report, so its failed action and "Cancel & reopen" appear immediately. - **Key backups never leave in a search.** Every admin API request URL passes the NIP-49 key-backup guard before it is signed or sent: community and legacy reads, mutations, the connection probe (before its first, unsigned request) and feedback-attachment fetches. The community and member search boxes, including the directory's Load more, refuse a pasted `nsec` or `ncryptsec` anywhere in the input without calling the relay. - **`just admin-seed` for multi-community testing.** Besides the local community, it seeds `beta.localhost:3000`, `gamma.localhost:3000` and 50 filler communities, with named members, a ban and a timeout in each named community, a report with Kick available, and a message for the delete preview. Rerunning it restores all of that to the starting state. It refuses, writing nothing, while a moderation action is unfinished on a seeded report. - The bodyless `GET /events/{id}` preview is added to the events-URL egress inventory. It sends no body, so it carries no key material. ## Tests Native (Rust): - `empty_404_and_405_read_as_complete_and_empty`, `coded_404_carries_the_relay_code`, `truncated_and_over_cap_bodies_are_incomplete`, `transport_failure_has_no_status`: the structured read error tells an older relay, a coded refusal, a cut-off body and a transport failure apart. - `a_401_retry_is_signed_by_the_same_keys`, `lift_signs_the_send_and_401_retry_with_the_checked_keys`: the 401 retry never switches keys. - `read_urls_validate_the_explicit_host_and_encode_the_query`, `lift_url_sends_the_page_host_not_the_active_relay`, `list_url_carries_host_and_cursor_and_refuses_a_changed_relay`, `lift_refuses_before_sending_on_signer_change_or_bad_host`: requests target the explicit host and refuse to send after a relay or signer change. jsdom: - `actions-pending-nav`: a pending intent survives a community change, and only Discard drops it. - `actions-fenced-lookup`, `fenced-load`: a late lookup for an old key never shows under, or unlocks, the new one. - `actions-same-ids`: the same pubkey and event id are read separately in each community. - `actions-community-change-drops-member`, `actions-page-host`, `actions-success`, `actions-identity`: the form acts only in the page's community, and confirm sends the frozen intent. - `actions-preview-errors`, `actions-delete-link`, `actions-staff-target`, `actions-disabled-auth`: preview errors, delete by note link, the staff block, and community pages rendering read-only with every action control disabled under disabled admin auth. - `communities-directory`, `communities-unsupported`, `community-badge`, `community-badge-disabled-auth`: the directory, older relays, and report badges, which open community pages under disabled admin auth too. - `restrictions-lift-signer-change`. The existing restriction tests now run on the community page and use `admin_lift_restriction`. Mutation checks: removing the pending-navigation guard turns `actions-pending-nav` red, and dropping the host from the fence key turns `actions-fenced-lookup`, `actions-same-ids` and `fenced-load` red. Ungating restriction lifts on `canMutate` turns `actions-disabled-auth` red. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -102,12 +102,6 @@ if ! docker exec buzz-minio mc alias set local http://localhost:9000 \
|
||||
exit 1
|
||||
fi
|
||||
|
||||
upload_fixture "${search_image}" "${search_image_hash}" png image/png 2000x1172
|
||||
upload_fixture "${workspace_image}" "${workspace_image_hash}" png image/png 2000x1172
|
||||
upload_fixture "${quality_image}" "${quality_image_hash}" png image/png 2000x1172
|
||||
upload_fixture "${composer_diagnostics}" "${composer_diagnostics_hash}" txt text/plain ""
|
||||
upload_fixture "${workspace_diagnostics}" "${workspace_diagnostics_hash}" txt text/plain ""
|
||||
|
||||
read -r -d '' sql <<'SQL' || true
|
||||
DO $$
|
||||
DECLARE
|
||||
@@ -190,7 +184,8 @@ BEGIN
|
||||
body = EXCLUDED.body,
|
||||
tags = EXCLUDED.tags,
|
||||
event_created_at = EXCLUDED.event_created_at,
|
||||
received_at = EXCLUDED.received_at;
|
||||
received_at = EXCLUDED.received_at,
|
||||
status = 'new';
|
||||
END $$;
|
||||
SQL
|
||||
|
||||
@@ -205,6 +200,186 @@ sql="${sql//__QUALITY_IMAGE_SIZE__/$(fixture_size "${quality_image}")}"
|
||||
sql="${sql//__COMPOSER_DIAGNOSTICS_SIZE__/$(fixture_size "${composer_diagnostics}")}"
|
||||
sql="${sql//__WORKSPACE_DIAGNOSTICS_SIZE__/$(fixture_size "${workspace_diagnostics}")}"
|
||||
|
||||
run_psql -v ON_ERROR_STOP=1 -c "${sql}"
|
||||
# Multi-community data: two named communities plus filler so the directory
|
||||
# pages past 50, named members, one ban and one timeout per named community,
|
||||
# extra reports and feedback, and one pre-signed kind-9 message each. The
|
||||
# messages were signed once with fixed test keys (secret = 5eed + the member
|
||||
# number, zero-padded to 32 bytes), so their signatures stay valid forever.
|
||||
read -r -d '' multi_sql <<'SQL' || true
|
||||
CREATE TEMP TABLE seed_community (host TEXT, channel_id UUID, author INT, event_id TEXT, sig TEXT, members INT[]);
|
||||
INSERT INTO seed_community VALUES
|
||||
('localhost:3000', 'c4a11e10-0000-4000-8000-000000000001', 1,
|
||||
'0fe4de81609e02e263b268f7069882f2db68dd74c3f62074be31eaab24b0b5fc',
|
||||
'63d4a64362bf1f91e56ea1f0001a196d4bc9a0e26712d8083516ffe054665a04fc7c3a13a554d327d0a37e0439bc67d9c0bf2252f595d9b8ce5a09587192e06d',
|
||||
'{1,2,5,6}'),
|
||||
('beta.localhost:3000', 'c4a11e10-0000-4000-8000-000000000002', 3,
|
||||
'50b89df773d8009a88d43ad707ebe0054c7e619a6c13727e0984aca0f930d03c',
|
||||
'b7d6c122ff4589498c05b3cabd26c7759d0acc497c747f9331130fd5f65e645203313b7c32ddfd97ac2fde0d2341aa4f189135e2b188d8bd7689a54e7c5787b4',
|
||||
'{1,3,5,6}'),
|
||||
('gamma.localhost:3000', 'c4a11e10-0000-4000-8000-000000000003', 4,
|
||||
'108f54732c522a97e2481b41b8b5a588a99c536ae25d5c0c2e9a1a0c28189f00',
|
||||
'839d536f36f998020596c080e2356f881a43af7d046b19b6e8cd214b8c097720cdeea5cc3173a60da8b671846580c4612c5e5fa1e34002ce6c3754bba683fb71',
|
||||
'{2,4,5,6}');
|
||||
|
||||
echo "Seeded 11 moderation reports, 7 feedback entries, and 5 attachments for the local admin dashboard."
|
||||
CREATE TEMP TABLE seed_member (n INT, pubkey BYTEA, display_name TEXT);
|
||||
INSERT INTO seed_member VALUES
|
||||
(1, decode('7a863b2e9a491321daf769357e398269d074730493cd1ca14812fbfd07cece70', 'hex'), 'Ada Lovelace'),
|
||||
(2, decode('ef1e7fbda3e8776e2cadd5ee1fc3cd3728014b18814ea21d15748aca6a1c5c27', 'hex'), 'Grace Hopper'),
|
||||
(3, decode('5929d0d248167876c88ded48bfd901389f644f5c47b967cab2c112ef9bf14f1a', 'hex'), 'Linus Pauling'),
|
||||
(4, decode('b75653278afb3d4522fdf8bd8d418c2085c2995c273d0357540c21374be953d5', 'hex'), 'Rosalind Franklin'),
|
||||
(5, decode('2ad6553f16e40b22045a68ffe6ba31ae5f400470d0e6276f377d50fc23f936a3', 'hex'), 'Spam Account'),
|
||||
(6, decode('9eb3e44e539549ff1436b6f98b3b583b8f89326d1cbee78a4edaea042cd34057', 'hex'), 'Heated Debater');
|
||||
|
||||
CREATE TEMP VIEW seeded AS
|
||||
SELECT s.*, c.id AS community_id
|
||||
FROM seed_community s
|
||||
JOIN communities c ON lower(c.host) = lower(s.host)
|
||||
OR (s.host = 'localhost:3000' AND c.id = :'local_community_id');
|
||||
|
||||
-- Refuse to reset while the admin console has unfinished work on a fixture:
|
||||
-- forcing a processing report back to open strands its action, and a pending
|
||||
-- action could re-apply after the reset. Locking admin actions blocks new
|
||||
-- claims and direct actions until this transaction commits.
|
||||
-- A claim or reopen locks its report row before writing an action, so the
|
||||
-- fixture rows are taken with NOWAIT: waiting on one would deadlock.
|
||||
LOCK TABLE relay_admin_actions IN SHARE ROW EXCLUSIVE MODE;
|
||||
CREATE TEMP VIEW seed_report AS
|
||||
SELECT r.* FROM moderation_reports r
|
||||
JOIN seeded s ON s.community_id = r.community_id
|
||||
JOIN (SELECT 'localhost:3000', to_hex(n) FROM generate_series(1, 11) n
|
||||
UNION ALL VALUES ('beta.localhost:3000', '61'), ('beta.localhost:3000', '62'),
|
||||
('gamma.localhost:3000', '63')) k(host, byte)
|
||||
ON k.host = s.host AND r.report_event_id = decode(repeat(lpad(k.byte, 2, '0'), 32), 'hex');
|
||||
DO $$
|
||||
BEGIN
|
||||
PERFORM 1 FROM moderation_reports r JOIN seed_report f USING (community_id, id) FOR UPDATE OF r NOWAIT;
|
||||
EXCEPTION WHEN lock_not_available THEN
|
||||
RAISE EXCEPTION 'admin-seed refused, nothing was written: moderation is active on a seeded report. Finish it in the admin console, then rerun just admin-seed.';
|
||||
END $$;
|
||||
CREATE TEMP TABLE seed_busy AS
|
||||
SELECT format('report %s in %s is %s', r.id, c.host, r.status) AS what
|
||||
FROM seed_report r JOIN communities c ON c.id = r.community_id
|
||||
WHERE r.status = 'processing' OR r.active_action_id IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT format('%s action %s in %s is %s', a.action, a.id, c.host, a.state)
|
||||
FROM relay_admin_actions a JOIN communities c ON c.id = a.report_community_id
|
||||
WHERE a.state IN ('pending', 'enforcing')
|
||||
AND a.report_community_id IN (SELECT community_id FROM seeded)
|
||||
AND (a.enforcement_target_pubkey IN (SELECT pubkey FROM seed_member)
|
||||
OR a.enforcement_target_event_id IN (SELECT decode(event_id, 'hex') FROM seed_community));
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM seed_busy) THEN
|
||||
RAISE EXCEPTION E'admin-seed refused, nothing was written: %\nFinish or cancel these in the admin console (Cancel & reopen for a failed report action), wait for pending actions to complete, then rerun just admin-seed.',
|
||||
(SELECT string_agg(what, '; ') FROM seed_busy);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
INSERT INTO communities (host)
|
||||
SELECT host FROM seed_community WHERE host <> 'localhost:3000'
|
||||
UNION ALL
|
||||
SELECT format('filler-%s.localhost:3000', lpad(n::text, 2, '0')) FROM generate_series(1, 50) n
|
||||
ON CONFLICT ((lower(host))) DO NOTHING;
|
||||
|
||||
INSERT INTO channels (community_id, id, name, created_by)
|
||||
SELECT community_id, channel_id, 'seed-enforcement-channel', decode(repeat('3b', 32), 'hex') FROM seeded
|
||||
ON CONFLICT (community_id, id) DO UPDATE SET name = EXCLUDED.name;
|
||||
|
||||
INSERT INTO users (community_id, pubkey, display_name)
|
||||
SELECT s.community_id, m.pubkey, m.display_name
|
||||
FROM seeded s JOIN seed_member m ON m.n = ANY (s.members)
|
||||
ON CONFLICT (community_id, pubkey) DO UPDATE SET display_name = EXCLUDED.display_name;
|
||||
|
||||
INSERT INTO relay_members (community_id, pubkey, role, added_by)
|
||||
SELECT s.community_id, encode(m.pubkey, 'hex'), 'member', NULL
|
||||
FROM seeded s JOIN seed_member m ON m.n = ANY (s.members)
|
||||
ON CONFLICT (community_id, pubkey) DO NOTHING;
|
||||
|
||||
INSERT INTO channel_members (community_id, channel_id, pubkey)
|
||||
SELECT community_id, channel_id, (SELECT pubkey FROM seed_member WHERE n = author) FROM seeded
|
||||
ON CONFLICT (community_id, channel_id, pubkey) DO UPDATE SET removed_at = NULL, removed_by = NULL;
|
||||
|
||||
INSERT INTO community_bans (community_id, pubkey, banned, ban_reason, muted_until, mute_reason, actor_pubkey)
|
||||
SELECT s.community_id, m.pubkey, m.n = 5,
|
||||
CASE WHEN m.n = 5 THEN 'Seeded ban: repeated spam.' END,
|
||||
CASE WHEN m.n = 6 THEN now() + interval '1 day' END,
|
||||
CASE WHEN m.n = 6 THEN 'Seeded timeout: heated thread.' END,
|
||||
decode(repeat('3b', 32), 'hex')
|
||||
FROM seeded s JOIN seed_member m ON m.n = ANY (s.members)
|
||||
ON CONFLICT (community_id, pubkey) DO UPDATE SET
|
||||
banned = EXCLUDED.banned, ban_expires_at = NULL, ban_reason = EXCLUDED.ban_reason,
|
||||
muted_until = EXCLUDED.muted_until, mute_reason = EXCLUDED.mute_reason, updated_at = now();
|
||||
|
||||
INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, channel_id)
|
||||
SELECT s.community_id, decode(s.event_id, 'hex'), m.pubkey, to_timestamp(1790000000), 9,
|
||||
jsonb_build_array(jsonb_build_array('h', s.channel_id::text)),
|
||||
format('Seeded message in %s for the admin delete preview.', s.host),
|
||||
decode(s.sig, 'hex'), s.channel_id
|
||||
FROM seeded s JOIN seed_member m ON m.n = s.author
|
||||
ON CONFLICT (community_id, created_at, id) DO UPDATE SET deleted_at = NULL;
|
||||
|
||||
INSERT INTO moderation_reports (
|
||||
community_id, id, report_event_id, reporter_pubkey, target_kind,
|
||||
target_event_id, target_pubkey, channel_id, report_type, note, status, created_at
|
||||
)
|
||||
SELECT s.community_id, ('a11d0000-0000-4000-8000-0000000001' || lpad(r.n::text, 2, '0'))::uuid,
|
||||
decode(repeat(to_hex(96 + r.n), 32), 'hex'), decode(repeat('1c', 32), 'hex'),
|
||||
r.kind, CASE WHEN r.kind = 'event' THEN decode(s.event_id, 'hex') END,
|
||||
CASE WHEN r.kind = 'pubkey' THEN (SELECT pubkey FROM seed_member WHERE n = 5) END,
|
||||
CASE WHEN r.kind = 'event' THEN s.channel_id END,
|
||||
r.report_type, format('%s report in %s.', initcap(r.report_type), s.host), 'open',
|
||||
now() - r.n * interval '7 minutes'
|
||||
FROM seeded s
|
||||
JOIN (VALUES (1, 'beta.localhost:3000', 'event', 'spam'),
|
||||
(2, 'beta.localhost:3000', 'pubkey', 'impersonation'),
|
||||
(3, 'gamma.localhost:3000', 'event', 'profanity')) r(n, host, kind, report_type)
|
||||
ON r.host = s.host
|
||||
ON CONFLICT (community_id, report_event_id) DO UPDATE SET
|
||||
channel_id = EXCLUDED.channel_id, status = EXCLUDED.status, resolved_by = NULL,
|
||||
resolved_at = NULL, created_at = EXCLUDED.created_at;
|
||||
|
||||
INSERT INTO product_feedback (id, community_id, event_id, submitter_pubkey, category, body, tags, event_created_at, received_at)
|
||||
SELECT ('feed0000-0000-4000-8000-0000000001' || lpad(f.n::text, 2, '0'))::uuid, s.community_id,
|
||||
decode(repeat(to_hex(112 + f.n), 32), 'hex'), (SELECT pubkey FROM seed_member WHERE n = f.member),
|
||||
f.category, format('%s (from %s)', f.body, s.host), jsonb_build_array(jsonb_build_array('category', f.category)),
|
||||
now() - f.n * interval '3 hours', now() - f.n * interval '3 hours'
|
||||
FROM seeded s
|
||||
JOIN (VALUES (1, 'beta.localhost:3000', 3, 'bug', 'Notifications arrive twice on mobile.'),
|
||||
(2, 'gamma.localhost:3000', 4, 'praise', 'Thread replies load instantly now.')) f(n, host, member, category, body)
|
||||
ON f.host = s.host
|
||||
ON CONFLICT (event_id) DO UPDATE SET
|
||||
community_id = EXCLUDED.community_id, body = EXCLUDED.body, event_created_at = EXCLUDED.event_created_at,
|
||||
received_at = EXCLUDED.received_at, status = 'new';
|
||||
SQL
|
||||
|
||||
# One transaction: the guard in multi_sql locks admin actions and refuses before
|
||||
# any write, so the reset cannot race a claim. Uploads only follow a commit.
|
||||
run_psql -q -1 -v ON_ERROR_STOP=1 -v local_community_id="${community_id}" \
|
||||
<<< "${multi_sql}"$'\n'"${sql}"
|
||||
|
||||
upload_fixture "${search_image}" "${search_image_hash}" png image/png 2000x1172
|
||||
upload_fixture "${workspace_image}" "${workspace_image_hash}" png image/png 2000x1172
|
||||
upload_fixture "${quality_image}" "${quality_image_hash}" png image/png 2000x1172
|
||||
upload_fixture "${composer_diagnostics}" "${composer_diagnostics_hash}" txt text/plain ""
|
||||
upload_fixture "${workspace_diagnostics}" "${workspace_diagnostics_hash}" txt text/plain ""
|
||||
|
||||
cat <<'EOF'
|
||||
Seeded 14 moderation reports, 9 feedback entries, and 5 attachments, plus
|
||||
beta.localhost:3000, gamma.localhost:3000 and 50 filler-NN.localhost:3000 communities.
|
||||
|
||||
Communities (with localhost:3000): beta.localhost:3000, gamma.localhost:3000
|
||||
Members (display name, npub, communities):
|
||||
Ada Lovelace npub102rrkt56fyfjrkhhdy6huwvzd8g8gucyj0x3eg2gztal6p7weecqlj45d5 localhost, beta
|
||||
Grace Hopper npub1au08l0drapmkut9d6hhpls7dxu5qzjccs982y8g4wj9v56sutsnshfethq localhost, gamma
|
||||
Linus Pauling npub1ty5ap5jgzeu8djyda4ytlkgp8z0kgn6ug7uk0j4jcyfwlxl3fudq59tr5r beta
|
||||
Rosalind Franklin npub1kat9xfu2lv752ghalz7c6svvyzzu9x2uyu7sx465pssnwjlf202s04ej99 gamma
|
||||
Spam Account npub19tt920ckus9jypz6drl7dw334e05qprs6rnzwmeh04g0cglex63s34trhf all three, banned
|
||||
Heated Debater npub1n6e7gnjnj4yl79pkkmuckw6c8w8cjvndrjlw0zjwmt4qgtxngptse52a73 all three, timed out 1 day
|
||||
Messages (rerunning the seed restores deleted ones):
|
||||
localhost:3000 note1pljdaqtqncpwycajdrmsdxyz7tdk3ht5c0mzqa97x842kf9skh7q9whkgc
|
||||
nevent1qqsqlex7s9sfuqhzvwex3acxnzp09kmgm46v8a3qwjlrr64tyjcttlqrmlq9s
|
||||
beta.localhost:3000 note12zufmamnmqqf4zx58tts06lqq4x8ucv6dsfhylsfsjk2p7fs6q7q2svrv4
|
||||
nevent1qqs9pwya7aeasqy63r2r44c8a0sq2nr7vxdxcymj0cycft9qlycdq0q2n8jdv
|
||||
gamma.localhost:3000 note1zz84guev2g4f0cjgrdqm3dd93z5ec5m2ufw4crpwngdqc2qcnuqqd7x0uy
|
||||
nevent1qqsppr65wvk9y25hufypksdckkjc32vu2d4wyh2upshf5xsv9qvf7qqft8acc
|
||||
EOF
|
||||
|
||||
Reference in New Issue
Block a user