Skip to content

feat(relay): NIP-98 admin auth with Operator/Moderator roles and NIP-11 discovery - #3777

Open
wpfleger96 wants to merge 6 commits into
mainfrom
wpfleger/admin-api-bearer-auth
Open

feat(relay): NIP-98 admin auth with Operator/Moderator roles and NIP-11 discovery#3777
wpfleger96 wants to merge 6 commits into
mainfrom
wpfleger/admin-api-bearer-auth

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 30, 2026

Copy link
Copy Markdown
Member

Replaces the BUZZ_ADMIN_INSECURE_NO_AUTH insecure-flag model with a two-tier principal model (Operator/Moderator) backed by a config-union-plus-DB roster and gated by NIP-98 HTTP auth, and adds NIP-11 auto-discovery so clients never have to type the admin URL by hand.

Authentication (BUZZ_ADMIN_AUTH)

Replaces the deleted BUZZ_ADMIN_INSECURE_NO_AUTH=true variable with BUZZ_ADMIN_AUTH accepting token (default), disabled, or nip98. Startup fails closed on missing or malformed configuration; any unrecognized value or conflicting combination aborts startup. Host/Origin matching is retained in all modes as defense-in-depth.

Principal resolution (NIP-98 mode)

resolve_admin_principal() returns AdminPrincipal { pubkey, role, source }:

  • Operator/Config — pubkey is in RELAY_OPERATOR_PUBKEYS
  • Operator/OwnerFallback — pubkey equals RELAY_OWNER_PUBKEY and RELAY_OPERATOR_PUBKEYS is empty (config-evaluated, never from runtime DB rows)
  • Operator or Moderator / Db — row in the relay_operators table
  • No match → 403

Config always outranks DB, and None never falls through as a role. A malformed RELAY_OWNER_PUBKEY alongside nip98 is a startup error, since the owner key can be a break-glass root and silently discarding it would be a lockout.

Token and disabled modes

Read routes work in all modes. Mutation and staffing routes require nip98; token and disabled modes receive 403 from require_mutation_principal. disabled mode logs a WARN on every boot and relies entirely on network-layer controls.

Report resolution and feedback

POST /reports/{id}/resolve is an enforcement state machine with idempotency:

  • Decision-only (dismiss/escalate): CAS open→terminal plus an audit row in one transaction.
  • Enforcement (delete/kick/ban/timeout): claims the report (open→processing), runs the durable mutation, then finalizes to resolved. Crash-safe — re-drive resumes at the step marker and converges to exactly-one enforcement, fenced by a lease and a claim token on the outbox.

PATCH /feedback/{id} updates product_feedback.status (new/reviewed/archived); requires nip98.

Staffing endpoints

GET/PUT/DELETE /operators/{pubkey} are Operator-only. PUT/DELETE against a config-backed pubkey returns 409 Conflict. GET /operators returns the union of config and DB principals with per-entry source attribution.

Probe endpoint

GET /probe reports auth mode, role, source, canAct, and canStaff for the desktop console.

NIP-11 admin-API auto-discovery

The NIP-11 relay-information document gains an optional admin_api field carrying the canonical admin origin (scheme://host[:port], no path), present iff the admin surface is configured (BUZZ_ADMIN_HOST set) and omitted entirely otherwise. The scheme follows the same loopback rule as NIP-98 u-tag verification (http for localhost/127.x/::1, else https), extracted into a shared scheme_for_host helper so the advertised origin and the origin the relay verifies against can never diverge. Clients read this to auto-discover the admin console instead of requiring manual URL entry.

Loopback example:

{ "admin_api": "http://127.0.0.1:3000" }

Operator API origin decoupling

RELAY_OPERATOR_API_ORIGIN is no longer required at boot when RELAY_OPERATOR_PUBKEYS is set. The allowlist is shared by the NIP-98 admin console (which needs no origin) and the community-provisioning endpoints (which do). Configuring the admin console no longer drags in an origin the operator does not use: the relay logs a WARN naming the affected feature, and the provisioning endpoints (POST /operator/communities) fail closed at request time — authorize_operator_request rejects with a clean 500 before any replay or DB access — until RELAY_OPERATOR_API_ORIGIN is set.

Migrations

  • 0029_relay_operators.sqlrelay_operators table (deployment-global; registered in _operator_global_tables), actor_authority on moderation_actions, processing status plus active_action_id on moderation_reports, status on product_feedback.
  • 0030_relay_admin_actions.sqlrelay_admin_actions enforcement-action table with a request_id idempotency key and a step_marker for crash recovery.
  • 0031_relay_admin_action_lease.sql — lease fencing for the action worker.
  • 0032_relay_admin_outbox_claim_token.sql — fenced claim token on the outbox worker.

Documentation

docs/admin/README.md documents the full principal model, NIP-98 event requirements (query string, method tag, payload tag for body mutations), owner-fallback semantics, role/source table, capabilities by role, roster management, the startup error matrix, the NIP-11 auto-discovery field, and the origin decoupling. .env.example and deploy/compose/.env.example describe RELAY_OPERATOR_API_ORIGIN and the admin console/provisioning split.


Related: block/buzz#4768 (desktop admin console consuming the admin_api field), squareup/bb-public#339 (Phase 4 rollout config)

@wpfleger96
wpfleger96 requested a review from a team as a code owner July 30, 2026 17:30
@cameronhotchkies cameronhotchkies added the triage-ready Appropriate for agentic review label Jul 30, 2026
@wpfleger96 wpfleger96 changed the title feat(relay): require a bearer token on the admin moderation API feat(relay): add authenticated admin API with bearer-token and network-layer modes Jul 30, 2026
@wpfleger96
wpfleger96 force-pushed the wpfleger/admin-api-bearer-auth branch 3 times, most recently from 3fcbdc0 to d014e40 Compare July 31, 2026 19:17
kalvinnchau
kalvinnchau previously approved these changes Jul 31, 2026

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@wpfleger96
wpfleger96 force-pushed the wpfleger/admin-api-bearer-auth branch from d014e40 to e93d5be Compare August 3, 2026 19:40
@wpfleger96 wpfleger96 changed the title feat(relay): add authenticated admin API with bearer-token and network-layer modes feat(relay): add authenticated admin API — bearer token, NIP-98 pubkey allowlist, and disabled modes Aug 3, 2026
@wpfleger96
wpfleger96 force-pushed the wpfleger/admin-api-bearer-auth branch 2 times, most recently from 9d54f68 to 1682a5e Compare August 3, 2026 20:22
@wpfleger96 wpfleger96 changed the title feat(relay): add authenticated admin API — bearer token, NIP-98 pubkey allowlist, and disabled modes feat(relay): OPERATOR/MODERATOR role model for relay admin API with NIP-98 auth Aug 7, 2026
Duncan and others added 3 commits August 10, 2026 19:37
…IP-98 auth

Gate the relay admin moderation API (/api/admin/v1) behind explicit
authentication configuration selected by BUZZ_ADMIN_AUTH: token (default),
disabled, or nip98. In nip98 mode every request carries a signed kind-27235
NIP-98 event; the authenticated pubkey resolves to an OPERATOR or MODERATOR
principal from RELAY_OPERATOR_PUBKEYS, the RELAY_OWNER_PUBKEY fallback, or the
relay_operators table. Replaces the BUZZ_ADMIN_INSECURE_NO_AUTH bypass with a
role model that is revocable without rotating a shared secret and fails closed
at every boundary.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Desktop had no way to discover the admin API endpoint and forced users to
type its URL by hand. Advertise the canonical admin origin
(scheme://host[:port], no path) in the NIP-11 relay-information document
under an optional admin_api field, present iff the admin surface is
configured (config.admin.is_some()).

Extract the loopback scheme rule into a shared scheme_for_host helper so the
advertised origin and the NIP-98 u-tag the relay verifies can never use
different schemes; a test enforces the invariant. The helper now parses IPv6
authorities (bracketed [::1]:3000 and bare ::1) correctly instead of letting
a colon-split mangle them.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
RELAY_OPERATOR_PUBKEYS is the shared allowlist for both the NIP-98 admin
console and the community-provisioning endpoints, but only provisioning needs
RELAY_OPERATOR_API_ORIGIN. The boot hard-error forced admin-console operators
to configure a provisioning surface they never use.

Demote the boot error to a WARN naming the affected feature, and keep the
provisioning endpoints fail-closed at request time: authorize_operator_request
already rejects with a clean 500 when the origin is unset, before any replay or
DB access. Document the decoupling and the NIP-11 admin_api advertisement in
the env examples and the admin README.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 force-pushed the wpfleger/admin-api-bearer-auth branch from 5527704 to 1cdc816 Compare August 11, 2026 00:19
@wpfleger96 wpfleger96 changed the title feat(relay): OPERATOR/MODERATOR role model for relay admin API with NIP-98 auth feat(relay): NIP-98 admin auth with Operator/Moderator roles and NIP-11 discovery Aug 11, 2026
Duncan and others added 3 commits August 10, 2026 22:01
A bare IPv6 admin host (BUZZ_ADMIN_HOST=::1) passed authority validation
but then interpolated unbracketed into the NIP-11 admin_api advertisement and
the NIP-98 u-tag canonical URL, yielding http://::1 — which no URL parser
accepts (an IPv6 authority must be bracketed per RFC 3986). Desktop discovery
rejected it and no client could match the malformed signed URL.

Reject the shape at config parse with an error naming the required bracketed
form, matching the documented exact-authority contract. This makes the
unbracketed multi-colon branch in scheme_for_host dead, so drop it. Replace the
auth.rs assertions that pinned http://::1 as expected output with parseability
tests; keep the advertised-vs-verified scheme-consistency invariant.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…g parse

The bare-IPv6 bracket guard names the honest `::1` shape but skips
unclosed-bracket typos like `[::1` and `[::1:3000` — they start with
`[`, pass the guard, then interpolate into an unparseable
`http://[::1` NIP-11 advertisement and NIP-98 u-tag URL. Same defect
class as the bare-IPv6 case, just a typo shape.

Add a catch-all after the bracket guard: url::Url::parse("http://{host}")
must succeed, else reject with an error naming the host. This is a
validity gate only — the host is still stored verbatim, not normalized.
It kills every malformed authority in one guard, including shapes not
enumerated. url is already a buzz-relay dep.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…parseable

The parse-only catch-all proved the whole `http://{host}` string is a
valid URL but not that {host} is exactly an authority. Query and fragment
delimiters are legal URL characters and were not in the forbidden set, so
`admin.example.com?x=1` and `[::1]#frag` passed startup: the suffix parsed
as query/fragment, then canonical_url appended the admin path after it
(`http://admin.example.com/?x=1/api/admin/v1/reports`), corrupting both the
NIP-11 advertisement and the NIP-98 u-tag URL — the same accepted-config/
unusable-URL class as the bare-IPv6 defect.

Validate the parsed sentinel structurally, mirroring parse_operator_api_origin:
host present, no credentials, path `/`, no query, no fragment. Any non-authority
character now lands in one of those and is rejected. Host still stored verbatim.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

triage-ready Appropriate for agentic review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants