feat(relay): OPERATOR/MODERATOR role model for relay admin API with NIP-98 auth - #3777
Open
wpfleger96 wants to merge 17 commits into
Open
feat(relay): OPERATOR/MODERATOR role model for relay admin API with NIP-98 auth#3777wpfleger96 wants to merge 17 commits into
wpfleger96 wants to merge 17 commits into
Conversation
wpfleger96
force-pushed
the
wpfleger/admin-api-bearer-auth
branch
3 times, most recently
from
July 31, 2026 19:17
3fcbdc0 to
d014e40
Compare
kalvinnchau
previously approved these changes
Jul 31, 2026
kalvinnchau
left a comment
Contributor
There was a problem hiding this comment.
Re-reviewed at d014e40. The fail-closed config contract, constant-time bearer validation, host/origin ordering, insecure network-boundary mode, dashboard token lifecycle, authenticated attachment fetches, and CSP/static routing are coherent and covered. Deployment dependency is external: land bb-public#339 and wait for Argo rollout before deploying this relay image.
The deployment-admin API at /api/admin/v1 exposed every moderation report, product feedback submission, submitter pubkey, and attachment blob to anyone who could reach the listener with the right Host header. Host and Origin matching is a routing constraint, not authentication. BUZZ_ADMIN_HOST now requires BUZZ_ADMIN_TOKEN and the relay fails closed at startup without it, so no deployment can be upgraded into an unauthenticated state. The credential is checked before Host and Origin so an unauthenticated caller cannot use the response code as an oracle for the expected admin host. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The dashboard keeps the operator bearer token in sessionStorage, but its documents and assets were served with no Content-Security-Policy: the existing policy is middleware on /api/admin/v1 only, which the SPA fallback bypasses. A header is used rather than a meta tag because frame-ancestors is ignored in meta. The policy is scoped to the admin host so the public bundle is unaffected, and allows blob: images because attachments are fetched with the token and rendered from object URLs. The browser suite now proves the attachment object URLs are revoked on both races (replacement/unmount and completion after unmount) and that concurrent 401s collapse into a single re-prompt. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The dashboard's security posture section covered the bearer token and the Host/Origin defense-in-depth but not the response-header CSP that now ships with every admin-host SPA response, leaving operators without the frame and script-origin guarantees they are relying on. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The admin document links /favicon.svg, but the admin-host fallback routed only /assets/*, so the icon 404'd on the one host that serves the dashboard. Vite already emits the file at the bundle root; the fallback now admits that exact path and nothing else, so the bundle directory stays unbrowsable. The CSP paragraph also claimed the policy restricts every network destination. CSP does not constrain top-level navigation, so an executing same-origin script can still navigate with the token in a URL. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… deployments Operators whose admin API is protected at the network layer (VPN, firewall, private ingress) can now set BUZZ_ADMIN_INSECURE_NO_AUTH=true to disable bearer auth while keeping Host/Origin checks as defense-in-depth. This is the intended mode for Block's bb-public relay, which runs behind WARP+Okta. Config semantics: - Only the exact value "true" enables disabled mode; any other non-empty value is a startup ConfigError (no silent typo-coercion). - BUZZ_ADMIN_TOKEN and BUZZ_ADMIN_INSECURE_NO_AUTH=true both set is a startup ConfigError (ambiguous intent). - BUZZ_ADMIN_HOST with neither remains the existing fail-closed error. - A prominent WARN is logged on every startup in disabled mode. SPA probe: the dashboard now probes auth mode on first load (one unauthenticated GET to /api/admin/v1/reports). 200 → skip prompt (relay is in insecure_no_auth mode). Anything else → token prompt (existing behavior). This makes the dashboard work without user interaction for network-layer-protected deployments. Tests added: - Rust (config): insecure_no_auth activates, both-set fails, junk values fail, empty string treated as unset (4 tests) - Rust (api::admin): disabled mode passes all routes, still rejects wrong host and mismatched origin (3 tests) - Playwright (auth): probe 200 skips prompt, probe non-200 shows prompt (2 tests) Docs: README, both CHANGELOGs, and both .env.examples rewritten to describe the final two-mode shape once. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The JSDoc stated true = relay accepted (no auth needed) and false = 401. The code returns true when auth is required (non-200 response) and false when the relay returned 200 (insecure_no_auth mode, no token needed). Correct the comment to match the implementation and function name. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
AdminConfig previously modelled the auth mode as token: Option<AdminToken> plus insecure_no_auth: bool, leaving two invalid states representable and bridging the gap with an .expect() on the hot request path. Replace with an AdminAuth::Token(AdminToken) | AdminAuth::InsecureNoAuth enum so the type system rules out the invalid states and the .expect() is deleted. Config-parse behaviour is byte-identical: same error strings, same WARN messages, same fail-closed matrix, same env var names. Existing tests are updated mechanically (constructor call sites and one pattern-match assertion). Also adds a one-sentence note to docs/admin/README.md that token-mode issues one by-design 401 probe per fresh browser session so operators do not alert on it as an attack. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…llowlist Deletes BUZZ_ADMIN_INSECURE_NO_AUTH. Replaces it with BUZZ_ADMIN_AUTH, a mode enum with three exact values: - unset / "token" — bearer token via BUZZ_ADMIN_TOKEN (unchanged default) - "disabled" — no auth; boot WARN retained (replaces INSECURE_NO_AUTH) - "nip98" — NIP-98 HTTP Auth via BUZZ_ADMIN_PUBKEYS allowlist All existing token/disabled invariants carry 1:1 under the new name. Junk values fail closed at startup with a ConfigError. NIP-98 mode (admin-moderation scope): - Parses BUZZ_ADMIN_PUBKEYS as comma-separated 64-hex pubkeys (deduped). - BUZZ_ADMIN_PUBKEYS required non-empty in nip98 mode; warn-and-ignore in token/disabled modes. BUZZ_ADMIN_TOKEN + nip98 = ConfigError. - authorize_nip98(): single Authorization: Nostr <base64 event> header, verify_nip98_event, deployment-scoped replay guard (admin-moderation), allowlist membership check. Uniform 401 on all failures; no oracle. - WWW-Authenticate: Nostr on 401 (Bearer stays in token mode) — the SPA uses this header to discover the auth mode. - Canonical URL: https://<BUZZ_ADMIN_HOST>/api/admin/v1<stripped-path>; http:// for loopback hosts (local dev). Axum strips the prefix before handlers; ADMIN_API_PREFIX constant re-adds it for NIP-98 verification. SPA (admin-web): - probeAuthMode() reads WWW-Authenticate: Bearer/Nostr/absent to return "token" | "nip98" | "disabled". - nip98 mode: signNip98() helper builds kind-27235 events via window.nostr (NIP-07); send() attaches Authorization: Nostr per request. - Nip07Screen: shown when nip98 mode is detected but window.nostr is absent; instructs operator to install nos2x or Alby. - 401 in nip98 mode re-signs once then surfaces the error (no infinite loop). Docs/config: - docs/admin/README.md rewritten to three-mode shape; migration note from BUZZ_ADMIN_INSECURE_NO_AUTH. - Both .env.example files updated (root and deploy/compose/). - CHANGELOG.md Unreleased sections updated with three-mode description. Tests: - Config matrix: all mode values, junk, every mutual exclusion, missing/invalid BUZZ_ADMIN_PUBKEYS. - NIP-98 route tests: wrong pubkey → 401, replay → 401, wrong u/method → 401, duplicate Authorization → 401, valid → 200, valid + wrong host → 403. - Regression pins: token-mode and disabled-mode behavior unchanged. - Playwright: nip98 mode without NIP-07 → instruction screen; mocked window.nostr happy path signs requests and renders dashboard. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96
force-pushed
the
wpfleger/admin-api-bearer-auth
branch
from
August 3, 2026 19:40
d014e40 to
e93d5be
Compare
wpfleger96
force-pushed
the
wpfleger/admin-api-bearer-auth
branch
from
August 3, 2026 20:08
cd6c06f to
9d54f68
Compare
…x crate changelog Three issues from Thufir pass-1: 1. Query-bearing requests now authenticate correctly. All five handlers now pass uri.path_and_query() to authorize() instead of uri.path(), so the canonical URL includes the query string (e.g. /api/admin/v1/reports? status=open&limit=100) and verify_nip98_event() compares the right URL. Two new route tests: query-bearing request with matching full-URL event -> 200; path-only event for a query-bearing request -> 401. 2. Bounded NIP-98 retry is now implemented. send() in nip98 mode re-signs and retries exactly once on the first 401 (handles clock skew / key rotation); a second 401 surfaces the error. Two new Playwright tests: first-401-then-200 asserts two distinct auth calls and eventual render; persistent-401 asserts the error state (role=alert / 'Could not load data') after exactly two attempts. 3. crates/buzz-relay/CHANGELOG.md Unreleased section rewritten to the three-mode shape (BUZZ_ADMIN_AUTH=token|disabled|nip98, BUZZ_ADMIN_ PUBKEYS). BUZZ_ADMIN_INSECURE_NO_AUTH retained only as a migration note. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96
force-pushed
the
wpfleger/admin-api-bearer-auth
branch
from
August 3, 2026 20:22
9d54f68 to
1682a5e
Compare
…arer-auth * origin/main: (65 commits) fix(desktop): route macos notification clicks (#4799) feat(mobile): sync themes per community (#3767) feat(desktop): sync themes per community (#3653) feat(desktop): cap OpenClaw agent parallelism at 5 (#4019) fix(buzz-agent): scope handoff cap per turn, not per session lifetime (#4805) Fix mobile message timeline bounce (#4862) Polish mobile bottom sheets and profile cards (#4911) Fix media attachment actions (#4849) fix(desktop): remove join API token control (#4897) fix(desktop): allow shared agent mentions (#4913) Polish mobile top navigation (#4778) fix(release): tag immutable desktop candidates (#4811) fix(channels): restrict private-channel invitations (#4612) fix(acp): reject unattended permission requests (#4609) fix(workflow): bind trigger author to the signed event (#4607) fix(git): revoke access for banned relay members (#4608) fix(agent): recover from unsupported image input instead of poisoning the turn (#4896) Define private managed agent wire protocol (#4593) fix(mobile): serialize channel sections sync (#3165) fix(desktop): make missing-command error actionable for released builds (#4802) ... Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> # Conflicts: # CHANGELOG.md
… model
Replace BUZZ_ADMIN_PUBKEYS allowlist with a typed principal model backed by
config union + DB. Implement Phase 1 of Plan v4 (Thufir-cleared spec).
## Principal resolution (fallback B)
resolve_admin_principal() returns AdminPrincipal { pubkey, role, source }:
- Operator/Config if pubkey ∈ RELAY_OPERATOR_PUBKEYS
- Operator/OwnerFallback if pubkey == RELAY_OWNER_PUBKEY AND
RELAY_OPERATOR_PUBKEYS is empty (config-evaluated only)
- Operator|Moderator/Db from relay_operators table otherwise
- None → 403, never a fall-through role
Config outranks DB: a DB row for a config-backed Operator is ignored.
RELAY_OWNER_PUBKEY malformed is a startup ConfigError (was warn-and-ignore).
BUZZ_ADMIN_PUBKEYS deleted. Role vocabulary is operator|moderator end-to-end.
## NIP-98 method/body binding
authorize() takes method + raw_body, returns Option<AdminPrincipal>:
- Body-bearing mutations (POST/PUT/PATCH/DELETE) require the NIP-98 payload
sha256 tag; requests without it are rejected 401 before any DB access.
- u tag built from config-derived host + full path-and-query (not inbound Host).
- Replay guard consumed only after cryptographic verification; Redis-down
fails closed.
- token/disabled modes: no principal returned, probe advertises no capabilities.
## Schema (migration 0028)
- relay_operators table (global, no community_id): pubkey BYTEA PK, role TEXT
CHECK(operator|moderator), added_by, created_at. Registered in
_operator_global_tables and in the hardcoded parser list in migration.rs.
- moderation_actions.actor_authority: TEXT NOT NULL DEFAULT 'community'
CHECK(community|relay_operator|relay_moderator). Backfills existing rows.
- moderation_reports: status CHECK extended with 'processing' for HTTP
enforcement claim (Phase 2); active_action_id UUID column added.
- product_feedback.status: TEXT NOT NULL DEFAULT 'new'
CHECK(new|reviewed|archived).
## Routes and probe
/probe endpoint: returns authMode, role, source, canAct, canStaff.
token/disabled modes: role=null, canAct=false, canStaff=false.
nip98 mode: role+source from resolved AdminPrincipal; canStaff=true for Operator only.
## buzz-db
relay_operators CRUD module: get, list, upsert, remove. Db methods added.
Migration count assertions updated to 28.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Renumber migration 0028_relay_operators → 0029_relay_operators to avoid collision with 0028_long_reaction_payloads that landed on main. Update migration count assertions in tests (28 → 29) and applied_versions check (Some(28) → Some(29)). CHANGELOG conflict resolved: keep Unreleased section above v0.5.6 release notes. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…rchestrations, staffing endpoints
Add HTTP report-resolution executor, durable enforcement state machine,
staffing API, and corrected admin documentation.
Relay (crates/buzz-relay):
- api/admin/auth.rs: fix payload tag check to condition on raw_body.is_some()
rather than method name — DELETE carries no body in the admin API;
update method_has_body doc/suppress dead_code for prod path
- api/admin/mod.rs: add POST /reports/{id}/resolve, PATCH /feedback/{id},
GET/PUT/DELETE /operators routes; ResolveReportBody with request_id
idempotency key; source-aware operator listing; 409 on config-backed upsert/
delete; test fixes — switch DB-dependent success-assertion tests to /probe
(no DB dependency); add mutation route in-process tests (token mode →403,
method/body substitution →401, missing payload tag →401, config-backed
PUT/DELETE →409, owner-fallback B upsert →409); 5 ignored e2e acceptance
test stubs (racing moderators, same-request_id retry, community 9044 vs
processing, cancel after mutation, worker crash re-drive)
- handlers/report_resolution.rs: two orchestrations — resolve_report_decision_only
(HTTP dismiss/escalate + 9044 adapter, single-transaction CAS open→terminal
with audit row) and resolve_report_with_enforcement (HTTP delete/kick/ban/
timeout, full claim/enforce/finalize state machine)
- handlers/mod.rs: register report_resolution module
buzz-db (crates/buzz-db):
- relay_admin_actions.rs: claim_report (CAS open→processing with decision audit
row + action record in one transaction, idempotent by request_id), begin_
enforcing, commit_mutation_step, finalize_success (action→succeeded +
report→resolved atomically), record_failure (pre-mutation only, step_marker IS
NULL guard), cancel_action (pre-mutation only, clears claim → report back to
open), deploy_kick_member (deployment-authority primitive; distinguishes
Removed from AlreadyGone, never blanket-converts MemberNotFound to success),
update_feedback_status, outbox enqueue/mark_delivered/list_pending
- lib.rs: expose all relay_admin_actions functions on Db
migrations:
- 0030_relay_admin_actions.sql: relay_admin_actions + relay_admin_outbox tables,
both registered in _operator_global_tables
docs:
- docs/admin/README.md: rewrite NIP-98 section to document actual OPERATOR/
MODERATOR principal model; remove deleted BUZZ_ADMIN_PUBKEYS allowlist
references; add principal resolution table, capabilities table, staffing
section, complete Routes section with mutation/staffing endpoints
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
report_detail_rejects_unknown_report and feedback_attachment_rejects_unknown_feedback both issue SQL queries against Postgres which is unavailable in unit tests. Without a DB the relay returns 500 instead of 404, causing false failures in CI. Mark both with #[ignore = "requires Postgres"] to match the established pattern for DB-dependent admin tests on this branch. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…rker, CAS fences, tests - Outbox rows now inserted inside claim_report transaction (atomicity guarantee: reporter_notice/tombstone/system_message rows exist even on process crash before request path re-enqueues them) - Add claim_pending_outbox_batch (SELECT FOR UPDATE SKIP LOCKED, lease-based), fail_outbox_row DB functions; wire Db wrapper methods - Implement admin_outbox_worker: background task polls relay_admin_outbox with DB-level leases (held_by/lease_expires_at), drives tombstone/system_message/ reporter_notice delivery; spawned from main.rs unconditionally - drive_enforcement rewritten as loop (eliminates recursive async fn compile error; CAS contention reloads + continues rather than recursing) - Fix remaining clippy lints: type_complexity on decode_report_target (module-level TargetPair alias), single_match in config.rs test (-> if let) - Replace 5 todo!() acceptance test placeholders with real Postgres-backed implementations; add 8 additional DB-layer tests in relay_admin_actions::tests covering: racing moderators, idempotent retry, cancel-post-mutation rejection, crash re-drive from step_marker, decision-only no-orphan-audit, outbox-in-claim, finalize-without-marker rejection, kick Removed vs AlreadyGone - Purge BUZZ_ADMIN_PUBKEYS from .env.example, deploy/compose/.env.example, CHANGELOG.md, crates/buzz-relay/CHANGELOG.md; replace with RELAY_OPERATOR_PUBKEYS + fallback B + relay_operators table description - Revert unrelated mobile/pubspec.lock drift Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ues, relay_operator authority Test setup used 16-byte UUID slices for report_event_id (constraint requires 32), 'public_group' for channel_type (not a valid enum value; use 'stream'), 'public' for channel visibility (use 'open'), and 'config' for actor_authority in moderation_actions inserts (constraint allows 'community'|'relay_operator'| 'relay_moderator'; 'config' is a source label, not an authority level). All 9 relay_admin_actions DB-layer tests now pass on a fresh Postgres instance. The 5 acceptance tests in api::admin also pass (requires DATABASE_URL set to the test DB). 6 pre-existing bridge test failures (Redis-gated) unchanged from origin/main baseline. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Incorporates desktop v0.5.7 release notes. Resolves CHANGELOG.md conflict: Unreleased relay auth section preserved above new v0.5.7 entry. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces the
BUZZ_ADMIN_INSECURE_NO_AUTH/BUZZ_ADMIN_PUBKEYSmodel with a two-tier principal model backed by config union + DB, gated by NIP-98 HTTP auth. Phases 1 and 2 of Plan v4 (Thufir-reviewed spec).What changed
Authentication (
BUZZ_ADMIN_AUTH)Replaces the deleted
BUZZ_ADMIN_INSECURE_NO_AUTH=truevariable withBUZZ_ADMIN_AUTHacceptingtoken(default),disabled, ornip98. Startup fails closed on missing/malformed configuration.Principal resolution (NIP-98 mode)
resolve_admin_principal()returnsAdminPrincipal { pubkey, role, source }:Operator/Config— pubkey ∈RELAY_OPERATOR_PUBKEYSOperator/OwnerFallback— pubkey ==RELAY_OWNER_PUBKEYandRELAY_OPERATOR_PUBKEYSis empty (config-evaluated, never from runtime DB rows)Operator or Moderator / Db— row in therelay_operatorstableConfig always outranks DB.
Nonenever falls through as a role.Token/disabled mode
Read routes work in all modes. Mutation and staffing routes require
nip98; token/disabled modes receive403fromrequire_mutation_principal.Phase 2: report resolution
POST /reports/{id}/resolve— enforcement state machine with idempotency:dismiss/escalate— CAS open→terminal + audit row in one transaction.delete/kick/ban/timeout— claims report (open→processing), runs durable mutation, finalizes toresolved. Crash-safe: re-drive picks up at the step marker and converges to exactly-one enforcement.PATCH /feedback/{id}— updateproduct_feedback.status(new|reviewed|archived). Requires nip98.Phase 2: staffing endpoints
GET/PUT/DELETE /operators/{pubkey}— Operator-only.PUT/DELETEagainst a config-backed pubkey returns409 Conflict.GET /operatorsreturns the union of config and DB with per-entry source attribution.Probe endpoint
GET /probe— auth-mode, role, source,canAct,canStaffdiscovery for the desktop console.Migrations
0029_relay_operators.sql—relay_operatorstable (deployment-global; registered in_operator_global_tables),actor_authoritycolumn onmoderation_actions,processingstatus +active_action_idonmoderation_reports,statuscolumn onproduct_feedback.0030_relay_admin_actions.sql—relay_admin_actionsenforcement-action table withrequest_ididempotency key andstep_markerfor crash recovery.Documentation
docs/admin/README.mdrewritten to document the full principal model, NIP-98 event requirements (including query string, method tag, payload tag for body mutations), owner fallback B semantics, role/source table, capabilities by role, roster management, and startup error matrix.Tests
915 unit tests pass. 10 ignored (
requires Postgres). 7 pre-existing failures (6 media tests + 1 telemetry flake) that also fail onorigin/mainwithout a local DB.