Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
486aca7
feat(relay): OPERATOR/MODERATOR role model for relay admin API with N…
Aug 10, 2026
20d065f
feat(relay): advertise admin API origin via NIP-11 admin_api field
Aug 11, 2026
6f55ac0
fix(relay): stop requiring operator API origin for admin console boot
Aug 11, 2026
666da1e
fix(relay): reject unbracketed IPv6 in BUZZ_ADMIN_HOST at config parse
Aug 11, 2026
b2fb224
fix(relay): reject any unparseable BUZZ_ADMIN_HOST authority at confi…
Aug 11, 2026
91042b9
fix(relay): require BUZZ_ADMIN_HOST to be a bare authority, not just …
Aug 11, 2026
37d20b1
feat(relay): add reopen/cancel admin endpoints and activeAction repor…
Aug 13, 2026
6abc670
fix(relay): make admin cancel atomic + ownership-fenced (cross-report…
Aug 13, 2026
584941b
fix(relay): derive event-report enforcement target from stored author
Aug 13, 2026
ac88b9b
fix(relay): sync schema.sql admin tables to migrations 0034/0035
Aug 13, 2026
05ca744
fix(relay): align outbox pending index with pgschema-representable shape
Aug 13, 2026
6157e92
fix(relay): claim NIP-98 replay slot only after authorization, attrib…
Aug 14, 2026
ac4917f
feat(relay): make admin token mode read-write via relay identity
Aug 19, 2026
cb746de
test(relay): pin token-mode read-write attribution in CI
Aug 19, 2026
39e102b
test(relay): make token-mode dismiss gate hermetic
Aug 19, 2026
53a0d49
fix(admin-auth): harden NIP-98 and Host/Origin security surface
wpfleger96 Aug 20, 2026
14f0215
ci(relay): run admin API auth-boundary tests in the unit job
Aug 20, 2026
104aa20
Merge remote-tracking branch 'origin/main' into wpfleger/admin-api-be…
Aug 20, 2026
e21ad31
ci(relay): run unrostered-signer replay test in the integration job
Aug 20, 2026
2dda46a
fix(admin-auth): audit roster mutations, bound timeout, canonicalize …
Aug 21, 2026
76633b5
Merge origin/main into wpfleger/admin-api-bearer-auth
Aug 21, 2026
2616c0f
fix(admin-auth): serialize roster audit pre-image and pin fixes at th…
Aug 21, 2026
09b6f1f
fix(relay): stamp roster audit at insertion time with total-order key
Aug 21, 2026
d20a2fe
fix(relay): order roster audit by seq, not the non-monotonic wall clock
Aug 21, 2026
f6ea3fb
feat(relay): default the operator report queue to the escalation back…
Aug 21, 2026
9ef4953
feat(relay): auto-escalate illegal reports at ingestion
Aug 21, 2026
64cad3e
Merge remote-tracking branch 'origin/main' into wpfleger/admin-api-be…
Aug 21, 2026
dbbb6b8
test(relay): drop useless vec in reopen-coherence test
Aug 21, 2026
0e66fc8
refactor(relay): remove admin API token auth, nip98 sole authenticate…
Aug 25, 2026
1647727
Merge remote-tracking branch 'origin/main' into wpfleger/admin-api-be…
Aug 25, 2026
6137dc8
docs(relay): update admin CSP test message for NIP-98 auth
Aug 25, 2026
5de30fd
Merge remote-tracking branch 'origin/main' into wpfleger/admin-api-be…
Aug 25, 2026
4dd026c
fix(relay): warn instead of failing boot on lingering BUZZ_ADMIN_TOKEN
Aug 25, 2026
b01474a
Merge remote-tracking branch 'origin/main' into wpfleger/admin-api-be…
Aug 25, 2026
1712411
test(relay): assert BUZZ_ADMIN_TOKEN startup warning fires
Aug 25, 2026
dd4e4c7
Merge remote-tracking branch 'origin/main' into wpfleger/admin-api-be…
Aug 25, 2026
f0cb7b2
fix(relay): address admin NIP-98 auth review findings
Aug 26, 2026
a237db1
Merge origin/main into wpfleger/admin-api-bearer-auth
Aug 26, 2026
7e73f15
fix(relay): carry timeout terms, tombstone actor, and public reason c…
Aug 26, 2026
6e0405b
Merge remote-tracking branch 'origin/main' into wpfleger/admin-api-be…
Aug 26, 2026
aca0dca
fix(relay): pin moderation regression seams and retry idempotency
Aug 26, 2026
1601de1
Merge remote-tracking branch 'origin/main' into wpfleger/admin-api-be…
Aug 26, 2026
8c2e46e
Merge remote-tracking branch 'origin/main' into wpfleger/admin-api-be…
Aug 27, 2026
d78e4bd
Merge remote-tracking branch 'origin/main' into wpfleger/admin-api-be…
Aug 27, 2026
7579ee7
fix(admin): wire admin-web to the relay feedback lifecycle
Aug 27, 2026
39ba02b
test(admin): pin the non-optimistic failed-PATCH feedback contract
Aug 27, 2026
31551f2
Merge remote-tracking branch 'origin/main' into wpfleger/admin-api-be…
Aug 27, 2026
1dc028a
Merge remote-tracking branch 'origin/main' into wpfleger/admin-api-be…
Aug 27, 2026
ff0c4c2
fix(relay): harden feedback attachments and protect the last operator
Aug 27, 2026
447f1e4
Merge remote-tracking branch 'origin/main' into wpfleger/admin-api-be…
Aug 27, 2026
33eea5e
test(relay): pin feedback-attachment response contract and select las…
Aug 27, 2026
8125665
Merge remote-tracking branch 'origin/main' into wpfleger/admin-api-be…
Aug 27, 2026
afe62e5
docs: drop hand-written changelog entries (auto-generated)
Aug 27, 2026
b2884b7
Merge remote-tracking branch 'origin/main' into wpfleger/admin-api-be…
Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
71 changes: 71 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 34 additions & 3 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading