From 86b9142a09f2af3ba2fff7effa6a6cd53b40f51c Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 28 Aug 2026 11:43:05 -0400 Subject: [PATCH] refactor(relay): NIP-98 admin auth with Operator/Moderator roles and NIP-11 discovery (#3777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds authenticated, role-based moderation to the relay admin API. On `main` the admin API is read-only and gated only by `Host`/`Origin` matching; this branch adds NIP-98 authentication, a two-tier Operator/Moderator principal model, mutation and staffing endpoints, and NIP-11 auto-discovery so clients never type the admin URL by hand. ## Authentication (`BUZZ_ADMIN_AUTH`) `BUZZ_ADMIN_AUTH` accepts `nip98` or `disabled`. Leaving it unset defaults to `nip98` (fail-secure). Configuration fails closed: any other value aborts startup, while a lingering `BUZZ_ADMIN_TOKEN` is ignored with a startup warning — token (bearer) authentication is not supported. `Host`/`Origin` matching is retained in every mode as defense-in-depth. - **`nip98`** (default) — per-request signed NIP-98 (kind 27235) events, resolved to an Operator or Moderator principal with per-person attribution and individual revocability. Read-write per resolved principal. - **`disabled`** — no credential; relies entirely on network-layer controls (reverse proxy, VPN, firewall) and logs a `WARN` on every boot. Always read-only: `authorize()` resolves no principal, so mutation and staffing routes always `403`. ## Roles Buzz has two independent authority axes after this change. **Relay-level** roles (new here) are deployment-global: they act across every community on the relay, through the admin API. **Community-level** roles (pre-existing, unchanged by this PR) are tenant-scoped: they act inside one community, through signed Nostr moderation commands. ### Relay level (new) | Role | Description | |---|---| | **Operator** | Full control of the deployment's moderation surface: read all reports, feedback, and attachments across every community; resolve reports with enforcement (`delete`/`kick`/`ban`/`timeout`) or decisions (`dismiss`/`escalate`); reopen and cancel; update feedback status; and manage the Operator/Moderator roster via the staffing endpoints. | | **Moderator** | Day-to-day triage: everything an Operator can do except staffing — cannot view or change the roster. | How a pubkey acquires a relay role (resolution order; config always outranks DB): 1. Listed in `RELAY_OPERATOR_PUBKEYS` → **Operator** (source `config`) 2. Equals `RELAY_OWNER_PUBKEY` while `RELAY_OPERATOR_PUBKEYS` is empty → **Operator** (source `owner_fallback`, a break-glass grant for self-hosters that deactivates once any operator is configured) 3. Row in the `relay_operators` table → **Operator** or **Moderator** (source `db`, managed via the staffing endpoints) 4. No match → `403` ### Community level (pre-existing, unchanged) | Role | Description | |---|---| | **Owner** (community) | Full authority within their community: every moderation action (delete, kick, ban/unban, timeout/untimeout, resolve reports, view queue) plus member, role, and invite management. No guard rails. | | **Admin** (community) | Same community-wide moderation capabilities as owner, except an admin cannot ban or time out the owner or a fellow admin — only the owner may action an admin. Manages members and invites; only the owner grants the admin role. | | **Member** (community) | Standard participant; no moderation capability. | | **Owner / Admin** (channel) | Channel-local authority only: delete messages and kick users within their own channel. | | **Member / Guest / Bot** (channel) | No moderation authority. | There is no community-level Moderator tier in v1; relay-level Moderator is the only role by that name. ## Escalation scoping The operator report queue is an escalation backstop, not the community's day-to-day triage surface (per `VISION_MODERATION`, the severe class is the platform's to review rather than the community's). Two rules enforce that: - **Escalated-by-default listing.** `GET /reports` with no `status` parameter returns only `escalated` reports. An explicit `status=` filter is always honored as given, and full visibility across every status stays available for platform-safety and legal review via `scope=all` (which lists reports regardless of status). `scope` accepts only `all` and is ignored when an explicit `status` is present. - **Auto-escalated `illegal` reports.** Member reports whose category is `illegal` are ingested with `status=escalated` rather than `open`, so the severe class reaches the operator backstop without waiting for a community admin to forward it. Every other category still lands `open`. Auto-escalation only sets the queue status — it records no moderator decision and stamps no resolver, so an auto-escalated report is indistinguishable downstream from an admin-escalated one: the reopen route returns it to `open` on the same terms, keyed only on status, never on how the report became escalated. ## Principal resolution and NIP-98 admission `resolve_admin_principal()` returns `AdminPrincipal { pubkey, role, source }` per the resolution order above; `None` never falls through as a role. Admission is ordered so the replay guard is a privilege, not a public surface: signature/URL/method/payload-hash verification first, roster check second, and only then is the deployment-scoped replay id atomically consumed — a validly-signing but unrostered key never allocates a replay slot. Redis failure fails closed. ## Report resolution, recovery, and enforcement provenance `POST /reports/{id}/resolve` is a crash-safe enforcement state machine: decision-only outcomes (`dismiss`/`escalate`) are a single CAS-plus-audit transaction; enforcement (`delete`/`kick`/`ban`/`timeout`) claims the report (`open`→`processing`), runs the durable mutation, then finalizes — a re-drive resumes at the step marker and converges to exactly-one enforcement, fenced by a lease and an outbox claim token. Person-directed enforcement on an `event`-kind report derives its target from the stored event's author (server-owned truth, never the reporter's `p` tag) via a single `derive_enforcement_target` shared by the HTTP driver and the recovery worker. If the reported event was purged before its author could be read, person-directed actions are rejected pre-claim and the report stays `open`; `delete` needs only the event id and is exempt. `GET /reports/{id}` and the resolve response carry an `activeAction` field surfacing the enforcement that actually executed — a report dismissed after a reopen still reports the ban that ran. `POST /reports/{id}/reopen` returns a terminal report to `open` (idempotent on `requestId`). `POST /reports/{id}/cancel` is the sole recovery path for a pre-mutation `failed` action, attributed via `relay_admin_actions.cancelled_by`. ## Feedback `GET /feedback` and `/feedback/{id}` survive a tenant purge: provenance columns are severed to `NULL` rather than cascade-deleted, and the attachment path fails closed to `404` on a severed row. `PATCH /feedback/{id}` updates lifecycle `status` (`new`/`reviewed`/`archived`). ## Staffing and probe `GET/PUT/DELETE /operators/{pubkey}` are Operator-only; mutating a config-backed pubkey returns `409 Conflict`. `GET /operators` returns the union of config and DB principals with per-entry `source`. `GET /probe` reports auth mode, role, source, `canAct`, and `canStaff` for the desktop console. ## NIP-11 auto-discovery The NIP-11 relay-information document gains an optional `admin_api` field carrying the canonical admin origin (`scheme://host[:port]`, no path), present iff `BUZZ_ADMIN_HOST` is set and omitted otherwise. The scheme follows the same loopback rule as NIP-98 `u`-tag verification via a shared `scheme_for_host` helper, so the advertised origin and the origin the relay verifies against can never diverge. ## Operator API origin decoupling `RELAY_OPERATOR_API_ORIGIN` is no longer required at boot when `RELAY_OPERATOR_PUBKEYS` is set — it is used only by the community-provisioning endpoints, which fail closed at request time (with a boot-time `WARN`) until it is set. The admin console needs no origin. ## Admin-web adaptation The standalone `admin-web` dashboard signs each request as a NIP-98 event via a NIP-07 browser extension, discovers the auth mode with a single unauthenticated probe (`200` → `disabled`, anything else → `nip98`, fail-secure), and carries no token entry surface. Playwright coverage exercises the NIP-98 and CSP paths. ## Security hardening Three findings from security review are folded in: - **Append-only roster audit.** `PUT`/`DELETE /operators/{pubkey}` mutate the deployment-wide root of trust, but the upsert overwrites `role`/`added_by` in place and the delete removes the only row — so a grant→revoke sequence left no trace of who was ever granted or by whom. Each mutation now writes an `relay_operator_audit` row (actor, target, `grant`/`revoke`, pre-image `prev_role`, `new_role`, timestamp) inside the same transaction as the mutation. A per-target transaction-scoped advisory lock serializes concurrent mutations of the same pubkey before the pre-image read, so the recorded `prev_role` is always the true predecessor even under a concurrent-grant race. Chronology is keyed on a `BIGINT GENERATED ALWAYS AS IDENTITY` `seq` column, not the wall clock: the serializing lock guarantees insertion order and `seq` captures it, so ordered reads (`ORDER BY seq`) follow the true privilege chain even across a backward NTP step that a `clock_timestamp()` ordering would invert. `created_at` (`clock_timestamp()`) is retained as informational occurrence time only. Append-only by construction — no `UPDATE`/`DELETE` path and no API surface. A no-op delete writes nothing. - **`expirationSecs` overflow.** The timeout path built `Utc::now() + Duration::seconds(secs as i64)` from an attacker-controlled `u64`: `i64::MAX` panicked the handler, and a wrapped-negative magnitude minted a *past* expiry that still passed validation. `compute_timeout_until` now rejects zero, rejects magnitudes above a documented `MAX_TIMEOUT_SECS` (365 days), and uses checked `try_seconds`/`checked_add_signed` so no input can panic or produce a past expiry — over-cap, zero, `i64::MAX`, and wrapping-negative inputs all return a clean `4xx`. - **Uppercase-hex config-backed bypass.** Config pubkeys are lowercased at parse, but the `409` immutability check raw-string-compared the path param while `decode_hex_pubkey` accepted uppercase — so `PUT /operators/{UPPERCASE}` skipped the guard and wrote a shadow row for the same 32 bytes. The validated param is now canonicalized (lowercased) before the `409` check, DB write, `DELETE`, and response body. ## Migrations - `0035_relay_operators.sql` — `relay_operators` roster table (deployment-global), `actor_authority` on `moderation_actions`, `processing` status plus `active_action_id` on `moderation_reports`, `status` on `product_feedback`. - `0036_relay_admin_actions.sql` — enforcement-action table with a `request_id` idempotency key, a `step_marker` for crash recovery, and a `cancelled_by` attribution column. - `0037_relay_admin_action_lease.sql` — lease fencing for the action worker. - `0038_relay_admin_outbox_claim_token.sql` — fenced claim token on the outbox worker. - `0039_relay_operator_audit.sql` — append-only `relay_operator_audit` trail for roster mutations (see Security hardening). `docs/admin/README.md` documents the full principal model, NIP-98 event requirements, capabilities by role, the startup error matrix, and the discovery field. ## Production blast radius A relay without `BUZZ_ADMIN_HOST` is completely unaffected — the admin surface stays disabled and `BUZZ_ADMIN_AUTH` is ignored; a lingering `BUZZ_ADMIN_TOKEN` logs a startup warning and must be removed. Where `BUZZ_ADMIN_HOST` **is** set, unset `BUZZ_ADMIN_AUTH` defaults to `nip98` (per-person signed auth); `BUZZ_ADMIN_AUTH=disabled` reproduces `main`'s prior `Host`/`Origin`-only gating but is read-only (mutation routes `403`). The five migrations add tables and columns without touching existing data. --- Related: [block/buzz#4768](https://github.com/block/buzz/pull/4768) (desktop admin console consuming the `admin_api` field), [squareup/bb-public#339](https://github.com/squareup/bb-public/pull/339) (Phase 4 rollout config) --------- Signed-off-by: Will Pfleger Signed-off-by: Duncan Co-authored-by: Duncan --- .env.example | 41 + .github/workflows/ci.yml | 71 + Justfile | 37 +- admin-web/src/App.tsx | 377 +- admin-web/src/api.ts | 168 +- admin-web/src/styles.css | 39 +- admin-web/src/types.ts | 16 +- admin-web/tests/auth.spec.ts | 521 ++ admin-web/tests/csp.spec.ts | 105 + admin-web/tests/feedback.spec.ts | 358 + admin-web/tests/routes.spec.ts | 12 +- crates/buzz-auth/src/nip98.rs | 178 +- crates/buzz-db/src/admin_moderation.rs | 431 +- crates/buzz-db/src/error.rs | 7 + crates/buzz-db/src/lib.rs | 432 + crates/buzz-db/src/migration.rs | 290 +- crates/buzz-db/src/moderation.rs | 132 +- crates/buzz-db/src/relay_admin_actions.rs | 3158 +++++++ crates/buzz-db/src/relay_operators.rs | 743 ++ crates/buzz-relay/src/api/admin/auth.rs | 606 +- crates/buzz-relay/src/api/admin/error.rs | 62 +- crates/buzz-relay/src/api/admin/mod.rs | 7268 ++++++++++++++++- crates/buzz-relay/src/api/media.rs | 142 + crates/buzz-relay/src/api/operator.rs | 61 + crates/buzz-relay/src/config.rs | 580 +- .../src/handlers/admin_action_worker.rs | 160 + .../src/handlers/admin_outbox_worker.rs | 370 + .../src/handlers/command_executor.rs | 1 + crates/buzz-relay/src/handlers/mod.rs | 4 + .../src/handlers/moderation_commands.rs | 99 +- .../src/handlers/moderation_notices.rs | 115 +- .../src/handlers/report_resolution.rs | 1026 +++ crates/buzz-relay/src/handlers/req.rs | 1 + .../buzz-relay/src/handlers/side_effects.rs | 42 +- crates/buzz-relay/src/main.rs | 25 + crates/buzz-relay/src/nip11.rs | 123 +- crates/buzz-relay/src/router.rs | 214 +- deploy/compose/.env.example | 21 + docs/admin/README.md | 370 +- migrations/0035_relay_operators.sql | 51 + migrations/0036_relay_admin_actions.sql | 84 + migrations/0037_relay_admin_action_lease.sql | 38 + .../0038_relay_admin_outbox_claim_token.sql | 9 + migrations/0039_relay_operator_audit.sql | 50 + schema/schema.sql | 148 +- 45 files changed, 18346 insertions(+), 440 deletions(-) create mode 100644 admin-web/tests/auth.spec.ts create mode 100644 admin-web/tests/csp.spec.ts create mode 100644 admin-web/tests/feedback.spec.ts create mode 100644 crates/buzz-db/src/relay_admin_actions.rs create mode 100644 crates/buzz-db/src/relay_operators.rs create mode 100644 crates/buzz-relay/src/handlers/admin_action_worker.rs create mode 100644 crates/buzz-relay/src/handlers/admin_outbox_worker.rs create mode 100644 crates/buzz-relay/src/handlers/report_resolution.rs create mode 100644 migrations/0035_relay_operators.sql create mode 100644 migrations/0036_relay_admin_actions.sql create mode 100644 migrations/0037_relay_admin_action_lease.sql create mode 100644 migrations/0038_relay_admin_outbox_claim_token.sql create mode 100644 migrations/0039_relay_operator_audit.sql diff --git a/.env.example b/.env.example index 1413441895..42f403e7a5 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,47 @@ RELAY_URL=ws://localhost:3000 # (use `just web` for Vite HMR instead). # BUZZ_WEB_DIR=./web/dist +# ----------------------------------------------------------------------------- +# Admin Dashboard (private moderation surface) +# ----------------------------------------------------------------------------- +# Host name that serves the moderation dashboard and its /api/admin/v1 +# endpoints. Leave unset to keep the admin surface absent. +# BUZZ_ADMIN_HOST=admin.localhost:3000 +# +# Authentication mode. Accepted values: nip98 (default), disabled. +# Any other value is a startup error. Token authentication was removed: +# BUZZ_ADMIN_TOKEN is ignored with a startup warning — remove it from the environment. +# BUZZ_ADMIN_AUTH=nip98 +# +# Option A — BUZZ_ADMIN_AUTH=nip98 (Nostr pubkey-based auth, default): +# NIP-98 HTTP Auth. Each request must carry an Authorization: Nostr header +# with a signed kind-27235 event. Authorized principals are resolved from: +# 1. RELAY_OPERATOR_PUBKEYS — comma-separated 64-char hex pubkeys (config Operators). +# 2. RELAY_OWNER_PUBKEY — implicit Operator fallback when RELAY_OPERATOR_PUBKEYS is unset. +# 3. relay_operators table — DB-managed Operator/Moderator roster. +# The dashboard requires a NIP-07 browser extension. +# Setting RELAY_OPERATOR_PUBKEYS for the admin console does NOT require +# RELAY_OPERATOR_API_ORIGIN; that origin is only for community provisioning +# (see below). When BUZZ_ADMIN_HOST is set, the relay advertises the admin +# origin in its NIP-11 document (`admin_api` field) so clients can auto-discover +# the console without manual URL entry. +# RELAY_OPERATOR_PUBKEYS=<64-char hex pubkey>[,<64-char hex pubkey>...] +# +# Option B — BUZZ_ADMIN_AUTH=disabled (network-layer auth only): +# Set only when the admin API is already protected at the network layer +# (VPN, private ingress). The relay logs a WARN on every startup. +# `just admin` defaults to this mode for local review. +# +# Directory holding the built dashboard assets (`pnpm -C admin-web build`). +# BUZZ_ADMIN_WEB_DIR=./admin-web/dist +# +# Canonical origin (http(s)://host[:port], no path) that community-provisioning +# NIP-98 requests are verified against. Required only to USE the provisioning +# endpoints (POST /operator/communities) — not for the admin console. When +# RELAY_OPERATOR_PUBKEYS is set but this is unset, the relay boots with a WARN +# and provisioning requests fail closed until it is set. +# RELAY_OPERATOR_API_ORIGIN=http://127.0.0.1:3000 + # Optional relay-owned KLIPY key. When set, NIP-11 advertises GIF search and # authenticated desktop clients use this relay as the metadata/search proxy. # Keep the real value in your deployment's secret manager; never commit it. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81b439a73d..4eb63944e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -780,6 +780,77 @@ jobs: --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Admin API nip98 read-write attribution test + # The only real HTTP → nip98 operator principal → mutation → cross-table + # attribution coverage: an authenticated operator's dismiss attributes + # to the operator's own key with relay_operator authority. Staffing + # PUT/DELETE attribution is covered by + # nip98_staffing_put_and_delete_write_attributed_audit_rows in the + # roster-audit lane below. #[ignore]d in the default suite — see + # api::admin::tests::nip98_operator_dismiss_succeeds_attributed_to_operator. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(=api::admin::tests::nip98_operator_dismiss_succeeds_attributed_to_operator)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Admin API unrostered-signer replay invariant + # The only causal proof that a validly-signing but unrostered key cannot + # consume NIP-98 replay slots: it asserts principal resolution fails + # BEFORE the replay ID is claimed (tracking.claim_count() == 0). This + # test is non-ignored, so it runs neither in Backend Integration's + # ignored-only selectors nor in the infra-free unit job — the unit job's + # api::admin selector excludes it because DB-free it only passes by + # waiting out the ~30s sqlx acquire timeout on a read-route fallthrough. + # It lives here so a reachable Postgres resolves (and fails) the lookup + # fast instead of timing out. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)' + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Admin API roster-audit / timeout / canonicalization security tests + # Security-review fixes for the roster admin API, all #[ignore]d in the + # default suite (they need Postgres) and selected by no other job: + # - buzz-db relay_operators::tests: audit pre-image trail, per-target + # lock serialization, insertion-time audit ordering, and + # audit-failure rollback coupling. + # - buzz-db relay_operators::tests last-operator invariant: sole DB + # operator cannot self-demote or self-delete to zero, config presence + # lifts the guard, and concurrent cross-target deletes racing to zero + # leave exactly one operator (roster-wide advisory lock). + # - buzz-relay api::admin: NIP-98 staffing writes attributed audit rows, + # adversarial expirationSecs rejected at the resolve route, mixed-case + # staffing normalizes to one canonical row. + # + # --test-threads=1: the last-operator invariant counts the roster + # globally, and the sole-operator tests clear the roster then assert + # their operator is the only one. They must not race each other on the + # shared test roster, so this lane runs serially. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + --test-threads=1 \ + -E '(package(buzz-db) and test(=relay_operators::tests::roster_mutations_write_pre_image_audit_rows)) or (package(buzz-db) and test(=relay_operators::tests::concurrent_upserts_serialize_and_record_true_pre_image)) or (package(buzz-db) and test(=relay_operators::tests::audit_order_follows_seq_under_backward_clock)) or (package(buzz-db) and test(=relay_operators::tests::audit_insert_failure_rolls_back_roster_mutation)) or (package(buzz-db) and test(=relay_operators::tests::demoting_sole_db_operator_without_config_is_rejected)) or (package(buzz-db) and test(=relay_operators::tests::deleting_sole_db_operator_without_config_is_rejected)) or (package(buzz-db) and test(=relay_operators::tests::config_present_allows_deleting_last_db_operator)) or (package(buzz-db) and test(=relay_operators::tests::concurrent_deletes_racing_to_zero_leave_one_operator)) or (package(buzz-relay) and test(=api::admin::tests::nip98_staffing_put_and_delete_write_attributed_audit_rows)) or (package(buzz-relay) and test(=api::admin::tests::resolve_route_rejects_adversarial_expiration_and_leaves_report_open)) or (package(buzz-relay) and test(=api::admin::tests::mixed_case_non_config_staffing_normalizes_to_one_row))' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Admin API escalation-scoping tests + # Escalation scoping for the moderation queue, all #[ignore]d (they need + # Postgres) and selected by no other job: + # - GET /reports defaults to the escalated-only backstop, scope=all + # restores full visibility, explicit status= overrides the default. + # - member reports with category 'illegal' auto-escalate at ingestion + # while every other category still lands 'open'. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E '(package(buzz-relay) and test(=api::admin::tests::reports_default_lists_escalated_only)) or (package(buzz-relay) and test(=api::admin::tests::reports_scope_all_lists_every_status)) or (package(buzz-relay) and test(=api::admin::tests::reports_explicit_status_filter_overrides_default)) or (package(buzz-db) and test(=moderation::tests::illegal_report_auto_escalates_at_ingest)) or (package(buzz-db) and test(=moderation::tests::non_illegal_report_lands_open_at_ingest)) or (package(buzz-db) and test(=relay_admin_actions::tests::auto_escalated_report_reopens_like_an_admin_escalated_one))' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Upload relay log if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/Justfile b/Justfile index 4ec1af2f44..6da2bb8b48 100644 --- a/Justfile +++ b/Justfile @@ -354,6 +354,31 @@ test-unit: # `cargo test --workspace`; without this step a manifest edit that # diverges Rust from the corpus ships green. cargo nextest run -p buzz-agent --lib + # Admin API auth-boundary tests (api::admin in buzz-relay): the NIP-98 + # duplicate-tag rejections, the Host/Origin replay-ordering causal pair, + # the admin.localhost origin/advertisement/canonical-URL pins, and the + # host-oracle/credential-first checks. These are the regression guard for + # the /api/admin/v1 moderation auth surface. Enumerated explicitly because + # nothing in CI runs `cargo test --workspace`, `just test-unit` did not + # enumerate `buzz-relay --lib`, and Backend Integration selects only the + # #[ignore]d Postgres suites — so these non-ignored tests ran in no lane + # and a red one could ship green (exactly how a broken admin test slipped + # past every gate once). Scoped to api::admin, not the whole buzz-relay + # --lib, because api::media has non-ignored tests that require Postgres. + # Two api::admin tests are excluded: both exercise a read-route DB + # fallthrough and pass without a database only by waiting out the sqlx + # acquire timeout (~30s each), so they do not belong in the infra-free + # unit job. nip98_mode_unrostered_signer_does_not_consume_a_replay_slot + # asserts a unique replay-guard invariant, so it is wired into the + # Postgres-backed Backend Integration job (see ci.yml "Admin API + # unrostered-signer replay invariant"). disabled_mode_allows_ + # unauthenticated_requests_on_the_admin_host has no unique invariant: + # disabled-mode unauthenticated success is covered by + # disabled_mode_regression_pin_unauthenticated_request_is_served on the + # DB-free /probe route, and its Host/Origin gating is covered here by + # disabled_mode_still_requires_the_correct_host / _a_matching_origin. + cargo nextest run -p buzz-relay --lib \ + -E 'test(/^api::admin::/) - test(=api::admin::tests::disabled_mode_allows_unauthenticated_requests_on_the_admin_host) - test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)' else ./scripts/run-tests.sh unit fi @@ -454,7 +479,7 @@ relay-web: bootstrap _ensure-migrations pnpm -C web build BUZZ_WEB_DIR=./web/dist cargo run -p buzz-relay -# Build and run the private read-only admin dashboard +# Build and run the private admin dashboard admin: bootstrap _ensure-migrations #!/usr/bin/env bash set -euo pipefail @@ -466,20 +491,26 @@ admin: bootstrap _ensure-migrations pnpm -C admin-web build export BUZZ_ADMIN_HOST="${BUZZ_ADMIN_HOST:-admin.localhost:3000}" export BUZZ_ADMIN_WEB_DIR="${BUZZ_ADMIN_WEB_DIR:-{{justfile_directory()}}/admin-web/dist}" + # Default to disabled auth locally: localhost is the network boundary and a + # NIP-07 signer extension can't be assumed in dev. Override per run with + # BUZZ_ADMIN_AUTH=nip98 (plus RELAY_OPERATOR_PUBKEYS or RELAY_OWNER_PUBKEY) + # to exercise the authenticated path. + export BUZZ_ADMIN_AUTH="${BUZZ_ADMIN_AUTH:-disabled}" echo "Admin dashboard: http://${BUZZ_ADMIN_HOST}/reports" + echo "Auth mode: ${BUZZ_ADMIN_AUTH} (set BUZZ_ADMIN_AUTH=nip98 to require a signed operator)" cargo run -p buzz-relay # Seed deterministic reports and product feedback for local admin dashboard review admin-seed: _ensure-migrations ./scripts/seed-admin-dashboard.sh -# Run focused relay and browser checks for the read-only admin dashboard +# Run focused relay and browser checks for the admin dashboard admin-check: fmt-check cargo check -p buzz-relay --all-targets cargo test -p buzz-relay api::admin cargo test -p buzz-relay router::tests pnpm -C admin-web check - pnpm -C admin-web exec playwright test + pnpm -C admin-web test:e2e # Start the relay server in release mode relay-release: bootstrap _ensure-migrations diff --git a/admin-web/src/App.tsx b/admin-web/src/App.tsx index f39ecc33c5..bc22f61561 100644 --- a/admin-web/src/App.tsx +++ b/admin-web/src/App.tsx @@ -6,9 +6,17 @@ import { useMemo, useState, } from "react"; -import { ApiFailure, request } from "./api"; +import { + type AuthMode, + ApiFailure, + mutate, + probeAuthMode, + request, + requestObjectUrl, +} from "./api"; import type { FeedbackDetail, + FeedbackStatus, FeedbackSummary, Report, ReportDetail as ReportDetailData, @@ -85,9 +93,9 @@ function StateView({ return resource.data ? children(resource.data) : null; } -function Reports() { +function Reports({ authMode }: { authMode: AuthMode }) { const resource = useResource( - () => request("/reports?status=open&limit=100"), + () => request("/reports?status=open&limit=100", authMode), "reports", ); return ( @@ -135,9 +143,9 @@ function Reports() { ); } -function ReportDetail({ id }: { id: string }) { +function ReportDetail({ id, authMode }: { id: string; authMode: AuthMode }) { const resource = useResource( - () => request(`/reports/${id}`), + () => request(`/reports/${id}`, authMode), id, ); return ( @@ -210,28 +218,29 @@ function ReportDetail({ id }: { id: string }) { ); } -function FeedbackList() { +function FeedbackList({ authMode }: { authMode: AuthMode }) { const resource = useResource( - () => request("/feedback"), + () => request("/feedback", authMode), "feedback", ); const [query, setQuery] = useState(""); const [community, setCommunity] = useState("all"); const [timeRange, setTimeRange] = useState("all"); const [statusFilter, setStatusFilter] = useState("all"); - const [statuses, setStatuses] = useState(loadFeedbackStatuses); + // Successful PATCH responses override the server-loaded status so the row + // reflects the new value without a full refetch. Keyed by feedback id. + const [overrides, setOverrides] = useState>( + {}, + ); - const updateStatus = (id: string, event: ChangeEvent) => { - const checked = event.target.checked; - setStatuses((current) => { - const next = { - ...current, - [id]: checked, - }; - saveFeedbackStatuses(next); - return next; - }); - }; + // Mutations require a named principal; disabled mode's server rejects them + // (probe reports canAct: false), so the write control is hidden there rather + // than offering an action that can only fail. + const canWrite = authMode === "nip98"; + + const applyStatus = useCallback((id: string, status: FeedbackStatus) => { + setOverrides((current) => ({ ...current, [id]: status })); + }, []); return ( {({ communities, filtered }) => ( <> @@ -301,8 +310,9 @@ function FeedbackList() { } > - - + + + @@ -327,22 +337,18 @@ function FeedbackList() { {item.bodySummary} - {item.communityHost} + {short(item.submitterPubkey)} - +
Received @@ -352,7 +358,7 @@ function FeedbackList() { className="record-open-link" > - Open feedback from {item.communityHost} + Open feedback from {hostLabel(item.communityHost)} @@ -372,13 +378,101 @@ function FeedbackList() { ); } +const STATUS_LABELS: Record = { + new: "New", + reviewed: "Reviewed", + archived: "Archived", +}; + +const STATUS_OPTIONS: FeedbackStatus[] = ["new", "reviewed", "archived"]; + +/// The per-row lifecycle control. In writable mode it PATCHes the relay and +/// adopts the status from the PATCH response (never optimistically — a failed +/// write leaves the prior status and surfaces an error). In read-only mode it +/// shows the authoritative status as a static badge, since disabled-mode +/// mutations are rejected server-side. +function FeedbackStatusControl({ + id, + status, + authMode, + canWrite, + onApplied, +}: { + id: string; + status: FeedbackStatus; + authMode: AuthMode; + canWrite: boolean; + onApplied: (id: string, status: FeedbackStatus) => void; +}) { + const [pending, setPending] = useState(false); + const [error, setError] = useState(false); + + if (!canWrite) { + return ( + + + {STATUS_LABELS[status]} + + + ); + } + + const onChange = async (event: ChangeEvent) => { + const next = event.target.value as FeedbackStatus; + setPending(true); + setError(false); + try { + const updated = await mutate<{ status: FeedbackStatus }>( + `/feedback/${encodeURIComponent(id)}`, + "PATCH", + { status: next }, + authMode, + ); + onApplied(id, updated.status); + } catch { + setError(true); + } finally { + setPending(false); + } + }; + + return ( + + ); +} + +/// Feedback whose source community was purged carries no host. Render an +/// explicit provenance-unavailable marker rather than an empty slot. +function Provenance({ host }: { host: string | null }) { + if (host) return host; + return Community unavailable; +} + +function hostLabel(host: string | null): string { + return host ?? "an unavailable community"; +} + function FeedbackResults({ items, query, community, timeRange, statusFilter, - statuses, + overrides, children, }: { items: FeedbackSummary[]; @@ -386,7 +480,7 @@ function FeedbackResults({ community: string; timeRange: string; statusFilter: string; - statuses: FeedbackStatuses; + overrides: Record; children: (results: { communities: string[]; filtered: FeedbackSummary[]; @@ -394,14 +488,14 @@ function FeedbackResults({ }) { const results = useMemo(() => { const communities = [...new Set(items.map((item) => item.communityHost))] - .filter(Boolean) + .filter((host): host is string => Boolean(host)) .sort((left, right) => left.localeCompare(right)); const normalizedQuery = query.trim().toLocaleLowerCase(); const after = timeRangeStart(timeRange); const filtered = items.filter((item) => { + const status = overrides[item.id] ?? item.status; if (community !== "all" && item.communityHost !== community) return false; - if (statusFilter === "pending" && statuses[item.id]) return false; - if (statusFilter === "acted-on" && !statuses[item.id]) return false; + if (statusFilter !== "all" && status !== statusFilter) return false; if (after !== undefined) { const receivedAt = new Date(item.receivedAt).valueOf(); if (Number.isNaN(receivedAt) || receivedAt < after) return false; @@ -409,19 +503,25 @@ function FeedbackResults({ if (!normalizedQuery) return true; return [ item.bodySummary, - item.communityHost, + item.communityHost ?? "", item.category ?? "uncategorized", item.submitterPubkey, ].some((value) => value.toLocaleLowerCase().includes(normalizedQuery)); }); return { communities, filtered }; - }, [items, query, community, timeRange, statusFilter, statuses]); + }, [items, query, community, timeRange, statusFilter, overrides]); return children(results); } -function FeedbackDetailView({ id }: { id: string }) { +function FeedbackDetailView({ + id, + authMode, +}: { + id: string; + authMode: AuthMode; +}) { const resource = useResource( - () => request(`/feedback/${id}`), + () => request(`/feedback/${id}`, authMode), id, ); return ( @@ -434,11 +534,16 @@ function FeedbackDetailView({ id }: { id: string }) { > {(feedback) => { - const attachments = feedbackAttachments( - feedback.id, - feedback.tags, - feedback.communityHost, - ); + // Without an authoritative host we cannot validate attachment URLs or + // derive their fetch paths safely, so we render none and mark the + // provenance unavailable rather than guessing an origin. + const attachments = feedback.communityHost + ? feedbackAttachments( + feedback.id, + feedback.tags, + feedback.communityHost, + ) + : []; const body = stripAttachmentMarkdown(feedback.body, attachments); return (
@@ -448,7 +553,9 @@ function FeedbackDetailView({ id }: { id: string }) {
-

{feedback.communityHost}

+

+ +

@@ -460,8 +567,9 @@ function FeedbackDetailView({ id }: { id: string }) {
{attachments.map((attachment) => ( ))}
@@ -488,10 +596,8 @@ function FeedbackDetailView({ id }: { id: string }) { ); } -type FeedbackStatuses = Record; - interface FeedbackAttachment { - url: string; + path: string; sourceUrl: string; mimeType: string; hash: string; @@ -500,25 +606,6 @@ interface FeedbackAttachment { filename?: string; } -const FEEDBACK_STATUS_KEY = "buzz-admin-feedback-status"; - -function loadFeedbackStatuses(): FeedbackStatuses { - try { - const stored = localStorage.getItem(FEEDBACK_STATUS_KEY); - return stored ? (JSON.parse(stored) as FeedbackStatuses) : {}; - } catch { - return {}; - } -} - -function saveFeedbackStatuses(statuses: FeedbackStatuses) { - try { - localStorage.setItem(FEEDBACK_STATUS_KEY, JSON.stringify(statuses)); - } catch { - // The controls remain useful for the current session if storage is blocked. - } -} - function timeRangeStart(range: string) { const durations: Record = { day: 24 * 60 * 60 * 1000, @@ -551,7 +638,7 @@ function feedbackAttachments( const parsedSize = Number(values.get("size")); return [ { - url: `/api/admin/v1/feedback/${encodeURIComponent(feedbackId)}/attachments/${hash}`, + path: `/feedback/${encodeURIComponent(feedbackId)}/attachments/${hash}`, sourceUrl: safeUrl, mimeType, hash, @@ -595,8 +682,17 @@ function stripAttachmentMarkdown( .trim(); } -function Attachment({ attachment }: { attachment: FeedbackAttachment }) { - const url = attachment.url; +function Attachment({ + attachment, + authMode, +}: { + attachment: FeedbackAttachment; + authMode: AuthMode; +}) { + const [objectUrl, setObjectUrl] = useState(); + const [objectType, setObjectType] = useState(); + const [failed, setFailed] = useState(false); + const path = attachment.path; const name = attachment.filename ?? `attachment-${attachment.hash.slice(0, 8)}`; const metadata = [ @@ -607,33 +703,74 @@ function Attachment({ attachment }: { attachment: FeedbackAttachment }) { .filter(Boolean) .join(" · "); - if (attachment.mimeType.startsWith("image/")) { + useEffect(() => { + // The API requires an Authorization header, so the bytes are fetched here + // and handed to the DOM as an object URL revoked on replacement/unmount. + let url: string | undefined; + let active = true; + setObjectUrl(undefined); + setObjectType(undefined); + setFailed(false); + requestObjectUrl(path, authMode) + .then((created) => { + if (!active) { + URL.revokeObjectURL(created.url); + return; + } + url = created.url; + setObjectUrl(created.url); + setObjectType(created.type); + }) + .catch(() => { + if (active) setFailed(true); + }); + return () => { + active = false; + if (url) URL.revokeObjectURL(url); + }; + }, [path, authMode]); + + const detail = failed ? "Could not load attachment" : metadata; + + // Inline rendering is gated on the server-VERIFIED blob type, never the + // reporter-supplied `attachment.mimeType`: the relay only labels sniffed + // passive raster images as `image/*` and forces everything else to + // `application/octet-stream`, so a hostile payload can never render inline. + if (objectUrl && objectType?.startsWith("image/")) { return (
- - {name} + + {name}
{name} - {metadata} + {detail}
); } - return ( - + const label = ( + <> {name} - {metadata} + {detail} + + ); + + // Until the bytes are fetched there is nothing a link could point at: the + // API path itself would open unauthenticated in a new tab. + if (!objectUrl) return
{label}
; + + // Non-image (or not-yet-typed) payloads are download-only. The object URL + // already wraps `application/octet-stream` bytes, and the `download` + // attribute with no `target="_blank"` means clicking saves the file rather + // than navigating to a typed document on the admin origin. + return ( +
+ {label} ); @@ -797,18 +934,72 @@ function ArrowIcon() { ); } +/// Shown in nip98 mode when no NIP-07 extension is available. Instructs the +/// operator to install nos2x or Alby before continuing. +function Nip07Screen() { + return ( +
+
+

Nostr extension required

+

+ This relay uses NIP-98 HTTP Auth. Install a NIP-07 browser extension + such as{" "} + + nos2x + {" "} + or{" "} + + Alby + + , then reload this page. Your Nostr key will be used to sign each + request. +

+ +
+
+ ); +} + export function App() { const { path } = usePath(); + + // Probe the relay once to discover the auth mode. `null` means the probe is + // still in flight. Once resolved, the mode is stable for the session. + const [authMode, setAuthMode] = useState(null); + useEffect(() => { + let active = true; + probeAuthMode().then((mode) => { + if (active) setAuthMode(mode); + }); + return () => { + active = false; + }; + }, []); + const report = path.match(/^\/reports\/([^/]+)$/); const feedback = path.match(/^\/feedback\/([^/]+)$/); + + // Probe still in flight — render nothing to avoid a visible flash. + if (authMode === null) return null; + + // NIP-98 mode: require a NIP-07 extension. + if (authMode === "nip98" && !(window as Window & { nostr?: unknown }).nostr) + return ; + const content = report ? ( - + ) : feedback ? ( - + ) : path === "/feedback" ? ( - + ) : ( - + ); return (
diff --git a/admin-web/src/api.ts b/admin-web/src/api.ts index 9e5aa7569f..e096dcbf3b 100644 --- a/admin-web/src/api.ts +++ b/admin-web/src/api.ts @@ -9,11 +9,114 @@ export class ApiFailure extends Error { } } -export async function request(path: string): Promise { - const response = await fetch(`${PREFIX}${path}`, { - credentials: "same-origin", - headers: { accept: "application/json" }, +/// The authentication mode the relay requires, discovered via the probe. +/// - `nip98` — NIP-98 HTTP Auth; each request signed with a NIP-07 extension +/// - `disabled` — relay returned 200 with no auth; no credential needed +export type AuthMode = "nip98" | "disabled"; + +/// Sign a NIP-98 kind-27235 event for the given URL + method via window.nostr. +/// Throws if window.nostr is not available or signing fails. +/// +/// A fresh random `nonce` tag is generated on every call. Without it, two +/// same-URL requests in the same second produce byte-identical events (the +/// signed fields are only `u`, `method`, and `created_at` at 1-second +/// resolution), so their event IDs collide — the relay's NIP-98 replay guard +/// rejects the second, and the 401 retry re-signs the same fields and can +/// never recover. The verifier ignores unknown tags, so the nonce is inert to +/// verification and serves only to make each signed event unique. +/// +/// For body-bearing methods the caller passes `body`; a `payload` tag carrying +/// the hex SHA-256 of the exact bytes is added and signed. The relay's verifier +/// rejects a body-bearing request whose `payload` tag is absent or mismatched +/// (auth.rs), so the hash must be over the identical bytes the request sends. +async function signNip98( + url: string, + method: string, + body?: Uint8Array, +): Promise { + const nostr = (window as Window & typeof globalThis & { nostr?: Nostr98 }) + .nostr; + if (!nostr) throw new Error("No NIP-07 extension available"); + const nonce = crypto.getRandomValues(new Uint8Array(16)); + const nonceHex = toHex(nonce); + const tags: string[][] = [ + ["u", url], + ["method", method], + ["nonce", nonceHex], + ]; + if (body !== undefined) { + tags.push(["payload", await sha256Hex(body)]); + } + const event = await nostr.signEvent({ + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + tags, + content: "", }); + return `Nostr ${btoa(JSON.stringify(event))}`; +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest("SHA-256", bytes as BufferSource); + return toHex(new Uint8Array(digest)); +} + +// Minimal type for the NIP-07 window.nostr interface. +interface Nostr98 { + signEvent(event: { + kind: number; + created_at: number; + tags: string[][]; + content: string; + }): Promise>; +} + +/// Every admin API call goes through here. Attaches the correct credential +/// for the current auth mode: +/// - `nip98` mode: sign a kind-27235 event via NIP-07 for each request +/// - `disabled` mode: no credential +/// +/// A 401 in nip98 mode re-signs once (handles key rotation / clock skew) +/// and retries the request exactly once; a second 401 surfaces the error — +/// no infinite loop. +async function send( + path: string, + accept: string, + authMode: AuthMode, + init?: { method?: string; body?: Uint8Array; contentType?: string }, +): Promise { + const method = init?.method ?? "GET"; + const body = init?.body; + const doRequest = async () => { + const headers: Record = { accept }; + if (init?.contentType) headers["content-type"] = init.contentType; + if (authMode === "nip98") { + const url = `${location.protocol}//${location.host}${PREFIX}${path}`; + headers.authorization = await signNip98(url, method, body); + } + return fetch(`${PREFIX}${path}`, { + method, + credentials: "same-origin", + headers, + body: body as BodyInit | undefined, + }); + }; + + let response = await doRequest(); + + if (response.status === 401 && authMode === "nip98") { + // Re-sign with a fresh event and retry exactly once (handles clock + // skew or key rotation). A second 401 surfaces the error below. + response = await doRequest(); + } + + if (response.status === 401) { + throw new ApiFailure(401, "The admin credential was rejected."); + } if (!response.ok) { const envelope = await response.json().catch(() => null); throw new ApiFailure( @@ -21,5 +124,62 @@ export async function request(path: string): Promise { envelope?.error?.message ?? `Request failed (${response.status})`, ); } + return response; +} + +export async function request(path: string, authMode: AuthMode): Promise { + const response = await send(path, "application/json", authMode); return response.json() as Promise; } + +/// Send a body-bearing mutation (PATCH/PUT/POST) and parse the JSON response. +/// The body is serialized once and signed over those exact bytes so the NIP-98 +/// `payload` tag matches what the relay verifies. +export async function mutate( + path: string, + method: string, + body: unknown, + authMode: AuthMode, +): Promise { + const bytes = new TextEncoder().encode(JSON.stringify(body)); + const response = await send(path, "application/json", authMode, { + method, + body: bytes, + contentType: "application/json", + }); + return response.json() as Promise; +} + +/// Probe whether the relay requires authentication. +/// Issues one unauthenticated request: +/// - 200 → `disabled` (no auth needed) +/// - anything else → `nip98` (the only authenticated mode; fail-secure) +export async function probeAuthMode(): Promise { + try { + const response = await fetch(`${PREFIX}/reports`, { + credentials: "same-origin", + headers: { accept: "application/json" }, + }); + return response.status === 200 ? "disabled" : "nip98"; + } catch { + // Network error — default to nip98 so a credential is required. + return "nip98"; + } +} + +/// Attachments cannot be fetched by `` or `` because those +/// carry no Authorization header. Callers render the object URL and must +/// revoke it when it is replaced or unmounted. +/// +/// Returns the server-verified `type` alongside the URL. The relay sniffs the +/// stored bytes and only labels verified passive raster images as `image/*`; +/// everything else is `application/octet-stream`. Callers must decide inline +/// rendering from this type, never from the untrusted reporter-supplied MIME. +export async function requestObjectUrl( + path: string, + authMode: AuthMode, +): Promise<{ url: string; type: string }> { + const response = await send(path, "*/*", authMode); + const blob = await response.blob(); + return { url: URL.createObjectURL(blob), type: blob.type }; +} diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css index 8f0ba3d6c3..c55b033a66 100644 --- a/admin-web/src/styles.css +++ b/admin-web/src/styles.css @@ -304,14 +304,31 @@ h1 { gap: 0.4rem; color: rgb(35 30 30 / 62%); font-size: 0.78rem; +} + +.feedback-status select { + font: inherit; + padding: 0.25rem 0.4rem; + border-radius: 0.4rem; + border: 1px solid rgb(35 30 30 / 18%); + background: #fff; + color: #231e1e; cursor: pointer; } -.feedback-status input { - width: 1rem; - height: 1rem; - margin: 0; - accent-color: #231e1e; +.feedback-status select:disabled { + opacity: 0.55; + cursor: progress; +} + +.status-error { + color: #b3261e; + font-size: 0.72rem; +} + +.provenance-unavailable { + font-style: italic; + color: rgb(35 30 30 / 45%); } .record-open-link { @@ -670,6 +687,18 @@ dd { color: #9f2424; } +.auth-prompt button { + margin-top: 1rem; +} + +.attachment-placeholder { + min-height: 8rem; + display: grid; + place-content: center; + color: rgb(35 30 30 / 48%); + font-size: 0.8rem; +} + @media (max-width: 720px) { .app-header { width: min(100% - 2rem, 1120px); diff --git a/admin-web/src/types.ts b/admin-web/src/types.ts index 6c10837758..042ec434bf 100644 --- a/admin-web/src/types.ts +++ b/admin-web/src/types.ts @@ -23,25 +23,33 @@ export interface ReportDetail extends Report { message: ReportedMessage | null; } +export type FeedbackStatus = "new" | "reviewed" | "archived"; + export interface FeedbackSummary { id: string; - communityId: string; - communityHost: string; + /// `null` once the source community is purged (provenance severed). + communityId: string | null; + /// `null` when `communityId` is severed — feedback retained without origin. + communityHost: string | null; submitterPubkey: string; category?: string; bodySummary: string; + status: FeedbackStatus; receivedAt: string; } export interface FeedbackDetail { id: string; - communityId: string; - communityHost: string; + /// `null` once the source community is purged (provenance severed). + communityId: string | null; + /// `null` when `communityId` is severed — feedback retained without origin. + communityHost: string | null; eventId: string; submitterPubkey: string; category?: string; body: string; tags: string[][]; + status: FeedbackStatus; eventCreatedAt: string; receivedAt: string; } diff --git a/admin-web/tests/auth.spec.ts b/admin-web/tests/auth.spec.ts new file mode 100644 index 0000000000..7f9ca37637 --- /dev/null +++ b/admin-web/tests/auth.spec.ts @@ -0,0 +1,521 @@ +import { expect, type Page, test } from "@playwright/test"; + +interface ObjectUrlLog { + created: string[]; + revoked: string[]; +} + +declare global { + interface Window { + objectUrlLog: ObjectUrlLog; + } +} + +/// Injects a minimal window.nostr stub that returns a fake signed event, so a +/// test can drive nip98 mode without a real NIP-07 extension. The stub derives +/// the event `id` from the signed fields (tags + created_at + content), so two +/// signings collide iff their signed payloads are byte-identical — exactly the +/// property the relay's replay guard keys on. +async function seedNip98(page: Page) { + await page.addInitScript(() => { + (window as Window & { nostr?: unknown }).nostr = { + signEvent: async (event: { + kind: number; + created_at: number; + tags: string[][]; + content: string; + }) => { + const serialized = JSON.stringify([ + event.kind, + event.created_at, + event.tags, + event.content, + ]); + // Cheap non-crypto digest of the signed fields, hex-padded to 64 chars. + let h = 0; + for (let i = 0; i < serialized.length; i++) { + h = (Math.imul(31, h) + serialized.charCodeAt(i)) | 0; + } + const id = (h >>> 0).toString(16).padStart(8, "0").repeat(8); + return { + ...event, + id, + pubkey: "b".repeat(64), + sig: "c".repeat(128), + }; + }, + }; + }); +} + +/// Decode an `Authorization: Nostr ` header to the signed event. +function decodeNostrHeader(header: string): { id: string; tags: string[][] } { + return JSON.parse(atob(header.replace(/^Nostr /, ""))); +} + +test("nip98 mode: attachments are fetched with a signed credential and rendered from blob urls", async ({ + page, +}) => { + const id = "feedback-with-attachments"; + const imageHash = "a".repeat(64); + const fileHash = "b".repeat(64); + const imageUrl = `https://design.buzz.xyz/media/${imageHash}.png`; + const fileUrl = `https://design.buzz.xyz/media/${fileHash}.txt`; + await seedNip98(page); + + const attachmentRequests: { path: string; authorization?: string }[] = []; + await page.route(`**/api/admin/v1/feedback/${id}/attachments/**`, (route) => { + attachmentRequests.push({ + path: new URL(route.request().url()).pathname, + authorization: route.request().headers().authorization, + }); + route.fulfill({ contentType: "application/octet-stream", body: "bytes" }); + }); + await page.route(`**/api/admin/v1/feedback/${id}`, (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + id, + communityId: "one", + communityHost: "design.buzz.xyz", + eventId: "31".repeat(32), + submitterPubkey: "21".repeat(32), + category: "bug", + body: "Composer froze.", + tags: [ + [ + "imeta", + `url ${imageUrl}`, + "m image/png", + `x ${imageHash}`, + "filename screenshot.png", + ], + [ + "imeta", + `url ${fileUrl}`, + "m text/plain", + `x ${fileHash}`, + "filename diagnostics.txt", + ], + ], + eventCreatedAt: "2026-07-17T17:25:00Z", + receivedAt: "2026-07-17T17:30:00Z", + }), + }), + ); + + await page.goto(`/feedback/${id}`); + await expect( + page.getByRole("img", { name: "screenshot.png" }), + ).toHaveAttribute("src", /^blob:/); + await expect( + page.getByRole("link", { name: /diagnostics.txt/ }), + ).toHaveAttribute("href", /^blob:/); + + expect(attachmentRequests.map((request) => request.path).sort()).toEqual( + [ + `/api/admin/v1/feedback/${id}/attachments/${imageHash}`, + `/api/admin/v1/feedback/${id}/attachments/${fileHash}`, + ].sort(), + ); + for (const request of attachmentRequests) { + expect(request.authorization).toMatch(/^Nostr /); + } +}); + +/// Records every object URL the SPA creates and revokes, so a test can prove a +/// blob handed to the DOM is released rather than merely replaced. +async function instrumentObjectUrls(page: Page) { + await page.addInitScript(() => { + const log: ObjectUrlLog = { created: [], revoked: [] }; + window.objectUrlLog = log; + const create = URL.createObjectURL.bind(URL); + const revoke = URL.revokeObjectURL.bind(URL); + URL.createObjectURL = (source: Blob | MediaSource) => { + const url = create(source); + log.created.push(url); + return url; + }; + URL.revokeObjectURL = (url: string) => { + log.revoked.push(url); + revoke(url); + }; + }); +} + +const FEEDBACK_ID = "feedback-with-attachments"; +const IMAGE_HASH = "a".repeat(64); +const FILE_HASH = "b".repeat(64); + +/// A feedback detail carrying one image and one non-image attachment. The +/// probe to `/reports` returns 200 so the SPA runs in disabled mode: these +/// tests exercise object-URL lifecycle, not authentication. +async function routeFeedbackDetail(page: Page) { + const host = "design.buzz.xyz"; + await page.route(`**/api/admin/v1/reports**`, (route) => + route.fulfill({ contentType: "application/json", body: "[]" }), + ); + await page.route(`**/api/admin/v1/feedback?**`, (route) => + route.fulfill({ contentType: "application/json", body: "[]" }), + ); + await page.route(`**/api/admin/v1/feedback/${FEEDBACK_ID}`, (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + id: FEEDBACK_ID, + communityId: "one", + communityHost: host, + eventId: "31".repeat(32), + submitterPubkey: "21".repeat(32), + category: "bug", + body: "Composer froze.", + tags: [ + [ + "imeta", + `url https://${host}/media/${IMAGE_HASH}.png`, + "m image/png", + `x ${IMAGE_HASH}`, + "filename screenshot.png", + ], + [ + "imeta", + `url https://${host}/media/${FILE_HASH}.txt`, + "m text/plain", + `x ${FILE_HASH}`, + "filename diagnostics.txt", + ], + ], + eventCreatedAt: "2026-07-17T17:25:00Z", + receivedAt: "2026-07-17T17:30:00Z", + }), + }), + ); +} + +test("attachment object urls are revoked when the view is left", async ({ + page, +}) => { + await instrumentObjectUrls(page); + await routeFeedbackDetail(page); + await page.route( + `**/api/admin/v1/feedback/${FEEDBACK_ID}/attachments/**`, + (route) => + route.fulfill({ contentType: "application/octet-stream", body: "bytes" }), + ); + + await page.goto(`/feedback/${FEEDBACK_ID}`); + const imageUrl = await page + .getByRole("img", { name: "screenshot.png" }) + .getAttribute("src"); + const fileUrl = await page + .getByRole("link", { name: /diagnostics.txt/ }) + .getAttribute("href"); + expect(imageUrl).toMatch(/^blob:/); + expect(fileUrl).toMatch(/^blob:/); + expect(await page.evaluate(() => window.objectUrlLog.revoked)).toEqual([]); + + await page.getByRole("link", { name: "Back to feedback" }).click(); + await expect(page.getByRole("heading", { name: "Feedback" })).toBeVisible(); + + await expect + .poll(() => page.evaluate(() => window.objectUrlLog.revoked)) + .toEqual(expect.arrayContaining([imageUrl, fileUrl])); +}); + +test("an attachment that arrives after the view is left is revoked immediately", async ({ + page, +}) => { + await instrumentObjectUrls(page); + await routeFeedbackDetail(page); + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + await page.route( + `**/api/admin/v1/feedback/${FEEDBACK_ID}/attachments/**`, + async (route) => { + await held; + await route.fulfill({ + contentType: "application/octet-stream", + body: "bytes", + }); + }, + ); + + await page.goto(`/feedback/${FEEDBACK_ID}`); + // Both fetches are held, so no blob exists yet. + await expect(page.getByText("Loading…")).toBeVisible(); + expect(await page.evaluate(() => window.objectUrlLog.created)).toEqual([]); + + // Leave before either fetch resolves, then let both complete. + await page.getByRole("link", { name: "Back to feedback" }).click(); + await expect(page.getByRole("heading", { name: "Feedback" })).toBeVisible(); + release(); + + await expect + .poll(() => page.evaluate(() => window.objectUrlLog.revoked.length)) + .toBe(2); + const log = await page.evaluate(() => window.objectUrlLog); + expect(log.revoked.sort()).toEqual(log.created.sort()); + // Nothing was ever handed to the DOM: the blobs outlived their view. + await expect(page.getByRole("img", { name: "screenshot.png" })).toHaveCount( + 0, + ); +}); + +test("probe: disabled mode renders directly when the probe returns 200", async ({ + page, +}) => { + // The probe to /api/admin/v1/reports returns 200, indicating the relay runs + // in disabled mode. The dashboard must render directly with no credential. + await page.route("**/api/admin/v1/reports**", (route) => + route.fulfill({ contentType: "application/json", body: "[]" }), + ); + + await page.goto("/reports"); + + await expect( + page.getByRole("heading", { name: "Open reports" }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Nostr extension required" }), + ).toHaveCount(0); +}); + +test("probe: nip98 mode without a NIP-07 extension shows the installation screen", async ({ + page, +}) => { + // The probe returns 401 and window.nostr is NOT injected, so the dashboard + // must show the extension installation screen instead of the dashboard. + await page.route("**/api/admin/v1/**", (route) => + route.fulfill({ + status: 401, + headers: { "www-authenticate": "Nostr" }, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "nip98 required" }, + }), + }), + ); + + await page.goto("/reports"); + + await expect( + page.getByRole("heading", { name: "Nostr extension required" }), + ).toBeVisible(); + await expect(page.getByRole("heading", { name: "Open reports" })).toHaveCount( + 0, + ); +}); + +test("probe: nip98 mode with a mocked NIP-07 extension signs requests and renders the dashboard", async ({ + page, +}) => { + await seedNip98(page); + + const authorizationHeaders: (string | undefined)[] = []; + await page.route("**/api/admin/v1/**", async (route) => { + const headers = route.request().headers(); + authorizationHeaders.push(headers.authorization); + // Probe: return 401 to trigger nip98 mode detection. + if (!headers.authorization) { + await route.fulfill({ + status: 401, + headers: { "www-authenticate": "Nostr" }, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "nip98 required" }, + }), + }); + } else { + // Any Authorization: Nostr header → accept. + await route.fulfill({ contentType: "application/json", body: "[]" }); + } + }); + + await page.goto("/reports"); + + await expect( + page.getByRole("heading", { name: "Open reports" }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Nostr extension required" }), + ).toHaveCount(0); + // The authenticated request used Authorization: Nostr. + const authenticatedHeaders = authorizationHeaders.filter(Boolean); + expect(authenticatedHeaders.length).toBeGreaterThan(0); + for (const h of authenticatedHeaders) { + expect(h).toMatch(/^Nostr /); + } +}); + +test("nip98 mode: same-second retry re-signs with a distinct event id", async ({ + page, +}) => { + // Freeze the clock so both signings share created_at (1s resolution). With + // only u+method+created_at signed, the two events would be byte-identical + // and collide in the relay's replay guard, so the 401 retry could never + // recover. The per-signing random nonce tag must make the second event's id + // distinct despite the frozen clock. + await page.addInitScript(() => { + const FROZEN = 1_760_000_000_000; + const RealDate = Date; + // biome-ignore lint/suspicious/noExplicitAny: minimal Date shim for the test + (globalThis as any).Date = class extends RealDate { + constructor(...args: unknown[]) { + // biome-ignore lint/suspicious/noExplicitAny: forward constructor args + super(...(args.length ? (args as any) : [FROZEN])); + } + static now() { + return FROZEN; + } + }; + }); + await seedNip98(page); + + const authCalls: string[] = []; + let signCount = 0; + await page.route("**/api/admin/v1/**", async (route) => { + const headers = route.request().headers(); + if (!headers.authorization) { + await route.fulfill({ + status: 401, + headers: { "www-authenticate": "Nostr" }, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "nip98 required" }, + }), + }); + return; + } + authCalls.push(headers.authorization); + signCount++; + // First authenticated attempt → reject, forcing the re-sign + retry. + await route.fulfill( + signCount === 1 + ? { + status: 401, + headers: { "www-authenticate": "Nostr" }, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "rejected" }, + }), + } + : { contentType: "application/json", body: "[]" }, + ); + }); + + await page.goto("/reports"); + await expect( + page.getByRole("heading", { name: "Open reports" }), + ).toBeVisible(); + await page.waitForLoadState("networkidle"); + + expect(authCalls).toHaveLength(2); + const [first, second] = authCalls.map(decodeNostrHeader); + // Same frozen created_at, yet distinct ids — the nonce tag did its job. + expect(first.id).not.toBe(second.id); + const nonceOf = (tags: string[][]) => tags.find((t) => t[0] === "nonce")?.[1]; + expect(nonceOf(first.tags)).toBeTruthy(); + expect(nonceOf(second.tags)).toBeTruthy(); + expect(nonceOf(first.tags)).not.toBe(nonceOf(second.tags)); +}); + +test("nip98 mode: first-401-then-200 retries once and renders the dashboard", async ({ + page, +}) => { + // Models a credential that is momentarily rejected (clock skew, key + // rotation) then accepted on the second attempt. + let signCount = 0; + await seedNip98(page); + + const authCalls: string[] = []; + await page.route("**/api/admin/v1/**", async (route) => { + const headers = route.request().headers(); + if (!headers.authorization) { + // Probe — announce nip98 mode. + await route.fulfill({ + status: 401, + headers: { "www-authenticate": "Nostr" }, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "nip98 required" }, + }), + }); + return; + } + authCalls.push(headers.authorization); + signCount++; + if (signCount === 1) { + // First authenticated attempt → reject. + await route.fulfill({ + status: 401, + headers: { "www-authenticate": "Nostr" }, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "rejected" }, + }), + }); + } else { + // Second attempt → accept. + await route.fulfill({ contentType: "application/json", body: "[]" }); + } + }); + + await page.goto("/reports"); + + // The retry should succeed. Wait for network to settle (both attempts + // complete) before asserting the authCalls count. + await page.waitForLoadState("networkidle"); + // Exactly two distinct Nostr credentials were sent (one per attempt). + expect(authCalls).toHaveLength(2); + for (const h of authCalls) { + expect(h).toMatch(/^Nostr /); + } +}); + +test("nip98 mode: persistent 401 surfaces error after exactly one retry", async ({ + page, +}) => { + // Every authenticated request returns 401. The SPA must attempt exactly + // two requests (first attempt + one retry) and then surface the error — + // never a third attempt. + await seedNip98(page); + + const authCalls: string[] = []; + await page.route("**/api/admin/v1/**", async (route) => { + const headers = route.request().headers(); + if (!headers.authorization) { + await route.fulfill({ + status: 401, + headers: { "www-authenticate": "Nostr" }, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "nip98 required" }, + }), + }); + return; + } + authCalls.push(headers.authorization); + await route.fulfill({ + status: 401, + headers: { "www-authenticate": "Nostr" }, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "rejected" }, + }), + }); + }); + + await page.goto("/reports"); + + // After the retry fails, the error state renders. The StateView shows + // "Could not load data" inside a role=alert region. + await expect(page.getByRole("alert")).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Could not load data" }), + ).toBeVisible(); + // Exactly two Nostr credentials sent — no third attempt. + await page.waitForLoadState("networkidle"); + expect(authCalls).toHaveLength(2); +}); diff --git a/admin-web/tests/csp.spec.ts b/admin-web/tests/csp.spec.ts new file mode 100644 index 0000000000..7cc0d830c1 --- /dev/null +++ b/admin-web/tests/csp.spec.ts @@ -0,0 +1,105 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; + +const ROUTER_RS = fileURLToPath( + new URL("../../crates/buzz-relay/src/router.rs", import.meta.url), +); + +/// The exact policy the relay serves on admin SPA documents. Read from the +/// relay source rather than copied, so this test can never pass against a +/// policy operators do not actually get. The preview server used here serves +/// no CSP of its own, so it is injected below. +function adminCsp() { + const source = readFileSync(ROUTER_RS, "utf8"); + const match = source.match(/const ADMIN_CSP: &str = "([^"]+)";/); + if (!match) throw new Error(`ADMIN_CSP not found in ${ROUTER_RS}`); + return match[1]; +} + +test("the relay admin csp does not break the built dashboard", async ({ + page, +}) => { + const csp = adminCsp(); + expect(csp).toContain("frame-ancestors 'none'"); + expect(csp).not.toContain("unsafe-inline"); + + // Every admin API call returns 200, so the probe resolves to disabled mode + // and the dashboard renders without a credential. + await page.route("**/api/admin/v1/**", (route) => + route.fulfill({ contentType: "application/json", body: "[]" }), + ); + // Only the document request: the API call to /reports carries a query string. + await page.route( + (url) => url.pathname === "/reports" && url.search === "", + async (route) => { + const response = await route.fetch(); + await route.fulfill({ + response, + headers: { ...response.headers(), "content-security-policy": csp }, + }); + }, + ); + + const violations: string[] = []; + page.on("console", (message) => { + if (message.text().includes("Content Security Policy")) + violations.push(message.text()); + }); + + await page.goto("/reports"); + + // Rendering at all proves the bundle's script and stylesheet loaded, and the + // empty state proves the fetch to /api/admin/v1 survived `connect-src 'self'`. + await expect( + page.getByRole("heading", { name: "Open reports" }), + ).toBeVisible(); + await expect(page.getByText("No records.")).toBeVisible(); + expect(violations).toEqual([]); +}); + +test("the linked favicon loads under the admin csp", async ({ page }) => { + const csp = adminCsp(); + + await page.route( + (url) => url.pathname === "/" && url.search === "", + async (route) => { + const response = await route.fetch(); + await route.fulfill({ + response, + headers: { ...response.headers(), "content-security-policy": csp }, + }); + }, + ); + + const violations: string[] = []; + page.on("console", (message) => { + if (message.text().includes("Content Security Policy")) + violations.push(message.text()); + }); + + await page.goto("/"); + + const href = await page.locator("link[rel=icon]").getAttribute("href"); + expect(href).toBe("/favicon.svg"); + + // Headless Chromium never issues the `` request itself, so + // load the same file the same way the policy sees it: an image fetch under + // `img-src 'self'`. A blocked fetch rejects `decode()`; a successful one + // proves the icon renders, and that the SVG's own inline