diff --git a/.env.example b/.env.example index 14134418955..42f403e7a59 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 81b439a73d1..4eb63944e41 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 3cd03874538..59871cadb36 100644 --- a/Justfile +++ b/Justfile @@ -347,6 +347,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 @@ -447,7 +472,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 @@ -459,20 +484,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 f39ecc33c50..bc22f615616 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); - - const updateStatus = (id: string, event: ChangeEvent) => { - const checked = event.target.checked; - setStatuses((current) => { - const next = { - ...current, - [id]: checked, - }; - saveFeedbackStatuses(next); - return next; - }); - }; + // 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>( + {}, + ); + + // 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 9e5aa7569fc..e096dcbf3b1 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 8f0ba3d6c3c..c55b033a664 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 6c108377589..042ec434bf0 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 00000000000..7f9ca376377 --- /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 00000000000..7cc0d830c12 --- /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