refactor(relay): NIP-98 admin auth with Operator/Moderator roles and NIP-11 discovery - #3777
Conversation
3fcbdc0 to
d014e40
Compare
kalvinnchau
left a comment
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.
d014e40 to
e93d5be
Compare
9d54f68 to
1682a5e
Compare
5527704 to
1cdc816
Compare
6d893a5 to
02aba40
Compare
…y-scoped nav gate Close the desktop half of Thufir's #4768 pass-1 findings that need no relay change. The relay-contract consumption (canonical action DTO, real cancel route) waits on #3777. Processing report rows were disabled in the list, but the enforcement progress/retry/cancel UI lives only inside the detail view — so the row was locked exactly when an operator needs to inspect a pending or failed action. Keep processing rows navigable; the detail view already suppresses the resolve form for any non-open report. Feedback triage `status` was optional on the wire types and silently defaulted to "new" when absent, misreporting a reviewed/archived entry as new after reload. Make `status` required on both feedback DTOs and read it directly, and type PATCH's actual `{status}` echo instead of claiming a full summary record. The Moderation nav resolver keyed its 60s cache on pubkey alone, but NIP-11 discovery is relay-dependent — a workspace switch could serve the previous relay's verdict. Key the resolver on the connected relay origin (and gate its `enabled` on a resolved origin), and defer the `?section=moderation` invalid-section redirect while the resolver is unresolved so a direct link is not bounced before the probe can authorize. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
1e0b013 to
0af7a91
Compare
…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>
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>
Carl's round-4 review found admin-web never caught up to the relay's server-backed feedback status and nullable provenance. - Add body/payload-capable NIP-98 signing: signNip98 takes an optional body and adds a payload sha256 tag over the exact bytes, and a mutate helper serializes the body once and signs those bytes so the relay verifier accepts the PATCH. Keeps the single 401 retry and nonce semantics. - Add status to FeedbackSummary/FeedbackDetail and replace the localStorage acted-on boolean with a server-backed tri-state control that PATCHes on change and adopts the status from the response (no optimistic lie; pending and error states). Read-only badge in disabled mode where mutations are rejected server-side. Removes the localStorage mechanism entirely. - Make communityId/communityHost nullable and guard search, community facet, and attachment derivation so purge-severed rows render a provenance- unavailable marker instead of crashing. - Fold /operators owner fallback and a DB row for the same pubkey into a single combined-source entry, matching against all accumulated entries rather than only config, so owner fallback is never demoted by a DB moderator row. - Update the admin README: the feedback status control is server-backed, not browser localStorage. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The failed-PATCH test asserted only the error alert, so an optimistic status update before the request would stay green. Gate the failing response so the test asserts the control is disabled while pending, then after the failure assert the control stays at the original server status and re-enables — the seam Carl's non-optimistic-lifecycle requirement protects. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…arer-auth * origin/main: fix(db): exclude kind:30179 ciphertext from brownfield FTS (#6822) Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> # Conflicts: # crates/buzz-db/src/migration.rs
d434742 to
31551f2
Compare
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Two blockers remain at exact head 31551f2e566ea6944b1fa6fb56f734605cae06c8:
-
Do not navigate attacker-controlled attachment bytes as typed blob documents. Feedback
imetasupplies the MIME and filename shown by the admin SPA. The authenticated attachment fetch preserves the media response type,requestObjectUrlturns it into a blob URL, and both the image and generic-file paths expose that URL throughtarget="_blank". A hash-valid active payload such as HTML or SVG can therefore be opened as a document derived by the admin origin, creating an execution/phishing surface for an operator reviewing hostile feedback. Never navigate untrusted bytes: serve them through a forced-attachment response or rebuild downloads asapplication/octet-stream, and inline-render only server-verified passive image formats. Add regressions for active MIME/extension combinations. -
Preserve at least one effective Operator across roster mutations.
PUT /operators/{pubkey}andDELETE /operators/{pubkey}require the caller to be an Operator, but then directly upsert/remove a DB row. The DB serializes only the target key; it has no roster-wide invariant. A sole DB-backed operator can demote or delete itself, and concurrent operators can mutate different targets down to zero, permanently removing staffing authority until config/DB repair. Enforce a transactionally serialized roster-wide invariant that at least one effective Operator remains, with self-demotion/deletion and concurrent-last-operator regression coverage. An add-first transfer flow would make the safe path explicit.
The prior feedback-lifecycle/nullability blockers are fixed at this head. I also verified the earlier stale-lease failure fence, affected-user notices, tombstone target/reason propagation, and per-request NIP-98 nonce fixes. CI’s code/test lanes are green; the remaining failed/skipped jobs are review workflow state, not evidence against these source-level blockers.
Evidence: App.tsx, api.ts, admin/mod.rs, relay_operators.rs.
…arer-auth * origin/main: fix(db): disable heartbeat vacuum truncation (#6898) chore(deps): update rui314/setup-mold digest to 7e4f20a (#6663) chore(deps): update dependency vitest to v4.1.11 (#6667) chore(deps): update dependency @tanstack/react-virtual to v3.14.10 (#6666) chore(deps): update ubuntu:24.04 docker digest to 33ceb71 (#6664) fix(projects): allow owners to delete agent projects (#6533) Fade expanded video controls on hover (#6926) Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> # Conflicts: # crates/buzz-db/src/migration.rs
Feedback attachment bytes, their imeta MIME, and filename are all supplied by untrusted reporters. The admin feedback route now serves them through serve_feedback_attachment, which re-derives the served type from a content sniff of the stored bytes: only verified passive raster images (png/jpeg/ gif/webp) render inline; every other payload — including HTML/SVG mislabelled image/* — is forced to application/octet-stream + Content-Disposition: attachment + nosniff so a hash-valid script payload cannot open as a document on the admin origin. admin-web mirrors this: inline <img> and download links are gated on the server-verified blob type, and non-image payloads are download-only with the target="_blank" navigation dropped. Roster mutations now preserve at least one effective operator. A demotion or delete that would leave no config-backed operator and no remaining DB operator row is rolled back with DbError::LastOperator (mapped to 409). The check is computed in the mutation transaction against the config snapshot the handler sees; a roster-wide advisory lock serializes operator-removing mutations across targets so two concurrent removals cannot both race the roster to zero. The add-first transfer is the documented safe handoff. Also pins the failed-PATCH test to assert the control still reads the persisted status while the mutation is in flight, so an optimistic set that rolls back on failure cannot pass green. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…arer-auth * origin/main: Fix Codex security review authorization (#6913) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…t-operator tests in CI Close two review-flagged test-gate defects on the admin moderation API. The relay attachment regression only exercised verified_inline_image_type on byte slices, never the browser-facing Content-Type/Disposition/nosniff mapping the handler emits — a wiring regression could restore navigable hostile content with the named test green. Extract the load-bearing response-policy decision into feedback_attachment_response_policy() (the seam the handler now calls) and pin its type+disposition pairing across verified raster, hostile HTML/SVG, and empty/short sniff prefixes. The four last-operator PG tests were #[ignore]d and selected by no CI job, so the transactional invariant and roster-wide lock could regress with CI fully green. Add all four to the roster-audit backend-integration selector. The last-operator invariant counts the roster globally and the sole-operator tests clear it, so they cannot race each other — the lane runs with --test-threads=1. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…arer-auth * origin/main: fix(cli): enrich template cardinality error with per-candidate presence and profile hints (#4825) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
No blocking findings at exact head 812566506dd9e9c3eaeee1584532b10e9d8d09a7 against base 0808ab485c39c3c12ef02af2188c65a5145eb99d.
I traced the admin-auth and moderation-action contract across NIP-98 verification, Operator/Moderator resolution, config and NIP-11 discovery, the report-resolution state machine, leased action/outbox recovery, database constraints, and the admin web consumer. The closed role/status/target/action sets agree across producers, wire types, persistence, and UI; malformed or unknown values fail closed. Replay is claimed only after signature and roster validation, privileged mutations are role-gated, stale action/outbox workers are fenced, and successful finalization transactionally creates deduplicated notices.
This was a read-only diff and exact-head source review; no PR code was checked out or executed. The stale migration numbering in the PR description is non-blocking documentation cleanup.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…arer-auth * origin/main: fix(ci): bump Codex CLI to 0.150.1 to unhang security review jobs (#6962) feat(desktop): implement 30178 team catalog backend (#5112) feat(model-capabilities): humanize Databricks UC model families (#6955) feat(agent): discover Databricks Unity Catalog models (#6918) test(db): use canonical channel roster fixtures (#6819) preserve channel description paragraph breaks (#6946) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
No blocking findings at exact head b2884b70114038ac24bb2e5ee9257f524ed6dbac against base 80177e4c8e97e7bf1f1a3760c4e3503aace22860.
I traced the admin-auth and moderation-action contract across NIP-98 verification and replay protection, Operator/Moderator resolution, config and NIP-11 discovery, all admin handlers, report enforcement/recovery, database constraints and migrations, leased action/outbox fencing, notices, and admin-web consumers. The closed role/status/target/action sets agree across producers, wire types, persistence, and UI; malformed or unknown values fail closed. Replay is claimed only after signature, roster, host, and origin validation; privileged mutations are role-gated; stale workers are fenced; and successful finalization transactionally creates deduplicated notices.
This was a read-only diff and exact-head source review; no PR code was checked out or executed. Non-blocking cleanup: the PR description's migration numbers and the ADMIN_CSP comment about a sessionStorage operator token are stale.
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Approved at b2884b70114038ac24bb2e5ee9257f524ed6dbac. I found no remaining code, product, or security blockers. The stale migration references in the PR description and stale ADMIN_CSP comment are non-blocking documentation cleanup.
…-history * origin/main: refactor(relay): NIP-98 admin auth with Operator/Moderator roles and NIP-11 discovery (#3777) refactor(db): split channel membership store (#6782) feat(auth): add NIP-FI canonical assertion verifier and contracts (#6776) fix(desktop): resolve exact typed mentions on space (#6862) perf(desktop): restore project context during startup (#6939) fix(desktop): lift right auxiliary pane above shared header backdrop (#6966) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Resolves the sole conflict in `Justfile`: #3777 and this branch each appended a new step to `test-unit` immediately after the buzz-agent lane. Both steps are wanted, so keep both — the admin api::admin selector from main, then the buzz-acp --lib lane from this branch. No semantic overlap: different packages, different selectors. Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
…y-scoped nav gate Close the desktop half of Thufir's #4768 pass-1 findings that need no relay change. The relay-contract consumption (canonical action DTO, real cancel route) waits on #3777. Processing report rows were disabled in the list, but the enforcement progress/retry/cancel UI lives only inside the detail view — so the row was locked exactly when an operator needs to inspect a pending or failed action. Keep processing rows navigable; the detail view already suppresses the resolve form for any non-open report. Feedback triage `status` was optional on the wire types and silently defaulted to "new" when absent, misreporting a reviewed/archived entry as new after reload. Make `status` required on both feedback DTOs and read it directly, and type PATCH's actual `{status}` echo instead of claiming a full summary record. The Moderation nav resolver keyed its 60s cache on pubkey alone, but NIP-11 discovery is relay-dependent — a workspace switch could serve the previous relay's verdict. Key the resolver on the connected relay origin (and gate its `enabled` on a resolved origin), and defer the `?section=moderation` invalid-section redirect while the resolver is unresolved so a direct link is not bounced before the probe can authorize. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…agent-edit * origin/main: Refresh mobile utility surfaces and theme picker (#6944) fix(desktop): complete project empty and context states (#6980) Fix mobile jump-to-latest flicker (#6807) refactor(relay): NIP-98 admin auth with Operator/Moderator roles and NIP-11 discovery (#3777) refactor(db): split channel membership store (#6782) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…rding-v3 * origin/main: Refresh mobile utility surfaces and theme picker (#6944) fix(desktop): complete project empty and context states (#6980) Fix mobile jump-to-latest flicker (#6807) refactor(relay): NIP-98 admin auth with Operator/Moderator roles and NIP-11 discovery (#3777) refactor(db): split channel membership store (#6782) feat(auth): add NIP-FI canonical assertion verifier and contracts (#6776) Signed-off-by: Clay Delk <clay.delk@gmail.com>
…enericize * origin/main: feat(desktop): add team sharing to community catalog (#3995) Refresh mobile utility surfaces and theme picker (#6944) fix(desktop): complete project empty and context states (#6980) Fix mobile jump-to-latest flicker (#6807) refactor(relay): NIP-98 admin auth with Operator/Moderator roles and NIP-11 discovery (#3777) refactor(db): split channel membership store (#6782) feat(auth): add NIP-FI canonical assertion verifier and contracts (#6776) fix(desktop): resolve exact typed mentions on space (#6862) perf(desktop): restore project context during startup (#6939) fix(desktop): lift right auxiliary pane above shared header backdrop (#6966) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…c-agent-commit-identity * origin/main: feat(desktop): add team sharing to community catalog (#3995) Refresh mobile utility surfaces and theme picker (#6944) fix(desktop): complete project empty and context states (#6980) Fix mobile jump-to-latest flicker (#6807) refactor(relay): NIP-98 admin auth with Operator/Moderator roles and NIP-11 discovery (#3777) refactor(db): split channel membership store (#6782) feat(auth): add NIP-FI canonical assertion verifier and contracts (#6776) fix(desktop): resolve exact typed mentions on space (#6862) perf(desktop): restore project context during startup (#6939) fix(desktop): lift right auxiliary pane above shared header backdrop (#6966) fix(ci): bump Codex CLI to 0.150.1 to unhang security review jobs (#6962) feat(desktop): implement 30178 team catalog backend (#5112) feat(model-capabilities): humanize Databricks UC model families (#6955) feat(agent): discover Databricks Unity Catalog models (#6918) test(db): use canonical channel roster fixtures (#6819) preserve channel description paragraph breaks (#6946) fix(cli): enrich template cardinality error with per-candidate presence and profile hints (#4825) Fix Codex security review authorization (#6913) fix(db): disable heartbeat vacuum truncation (#6898) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Adds authenticated, role-based moderation to the relay admin API. On
mainthe admin API is read-only and gated only byHost/Originmatching; this branch adds NIP-98 authentication, a two-tier Operator/Moderator principal model, mutation and staffing endpoints, and NIP-11 auto-discovery so clients never type the admin URL by hand.Authentication (
BUZZ_ADMIN_AUTH)BUZZ_ADMIN_AUTHacceptsnip98ordisabled. Leaving it unset defaults tonip98(fail-secure). Configuration fails closed: any other value aborts startup, while a lingeringBUZZ_ADMIN_TOKENis ignored with a startup warning — token (bearer) authentication is not supported.Host/Originmatching is retained in every mode as defense-in-depth.nip98(default) — per-request signed NIP-98 (kind 27235) events, resolved to an Operator or Moderator principal with per-person attribution and individual revocability. Read-write per resolved principal.disabled— no credential; relies entirely on network-layer controls (reverse proxy, VPN, firewall) and logs aWARNon every boot. Always read-only:authorize()resolves no principal, so mutation and staffing routes always403.Roles
Buzz has two independent authority axes after this change. Relay-level roles (new here) are deployment-global: they act across every community on the relay, through the admin API. Community-level roles (pre-existing, unchanged by this PR) are tenant-scoped: they act inside one community, through signed Nostr moderation commands.
Relay level (new)
delete/kick/ban/timeout) or decisions (dismiss/escalate); reopen and cancel; update feedback status; and manage the Operator/Moderator roster via the staffing endpoints.How a pubkey acquires a relay role (resolution order; config always outranks DB):
RELAY_OPERATOR_PUBKEYS→ Operator (sourceconfig)RELAY_OWNER_PUBKEYwhileRELAY_OPERATOR_PUBKEYSis empty → Operator (sourceowner_fallback, a break-glass grant for self-hosters that deactivates once any operator is configured)relay_operatorstable → Operator or Moderator (sourcedb, managed via the staffing endpoints)403Community level (pre-existing, unchanged)
There is no community-level Moderator tier in v1; relay-level Moderator is the only role by that name.
Escalation scoping
The operator report queue is an escalation backstop, not the community's day-to-day triage surface (per
VISION_MODERATION, the severe class is the platform's to review rather than the community's). Two rules enforce that:GET /reportswith nostatusparameter returns onlyescalatedreports. An explicitstatus=<open|resolved|dismissed|escalated>filter is always honored as given, and full visibility across every status stays available for platform-safety and legal review viascope=all(which lists reports regardless of status).scopeaccepts onlyalland is ignored when an explicitstatusis present.illegalreports. Member reports whose category isillegalare ingested withstatus=escalatedrather thanopen, so the severe class reaches the operator backstop without waiting for a community admin to forward it. Every other category still landsopen. Auto-escalation only sets the queue status — it records no moderator decision and stamps no resolver, so an auto-escalated report is indistinguishable downstream from an admin-escalated one: the reopen route returns it toopenon the same terms, keyed only on status, never on how the report became escalated.Principal resolution and NIP-98 admission
resolve_admin_principal()returnsAdminPrincipal { pubkey, role, source }per the resolution order above;Nonenever falls through as a role. Admission is ordered so the replay guard is a privilege, not a public surface: signature/URL/method/payload-hash verification first, roster check second, and only then is the deployment-scoped replay id atomically consumed — a validly-signing but unrostered key never allocates a replay slot. Redis failure fails closed.Report resolution, recovery, and enforcement provenance
POST /reports/{id}/resolveis a crash-safe enforcement state machine: decision-only outcomes (dismiss/escalate) are a single CAS-plus-audit transaction; enforcement (delete/kick/ban/timeout) claims the report (open→processing), runs the durable mutation, then finalizes — a re-drive resumes at the step marker and converges to exactly-one enforcement, fenced by a lease and an outbox claim token.Person-directed enforcement on an
event-kind report derives its target from the stored event's author (server-owned truth, never the reporter'sptag) via a singlederive_enforcement_targetshared by the HTTP driver and the recovery worker. If the reported event was purged before its author could be read, person-directed actions are rejected pre-claim and the report staysopen;deleteneeds only the event id and is exempt.GET /reports/{id}and the resolve response carry anactiveActionfield surfacing the enforcement that actually executed — a report dismissed after a reopen still reports the ban that ran.POST /reports/{id}/reopenreturns a terminal report toopen(idempotent onrequestId).POST /reports/{id}/cancelis the sole recovery path for a pre-mutationfailedaction, attributed viarelay_admin_actions.cancelled_by.Feedback
GET /feedbackand/feedback/{id}survive a tenant purge: provenance columns are severed toNULLrather than cascade-deleted, and the attachment path fails closed to404on a severed row.PATCH /feedback/{id}updates lifecyclestatus(new/reviewed/archived).Staffing and probe
GET/PUT/DELETE /operators/{pubkey}are Operator-only; mutating a config-backed pubkey returns409 Conflict.GET /operatorsreturns the union of config and DB principals with per-entrysource.GET /probereports auth mode, role, source,canAct, andcanStafffor the desktop console.NIP-11 auto-discovery
The NIP-11 relay-information document gains an optional
admin_apifield carrying the canonical admin origin (scheme://host[:port], no path), present iffBUZZ_ADMIN_HOSTis set and omitted otherwise. The scheme follows the same loopback rule as NIP-98u-tag verification via a sharedscheme_for_hosthelper, so the advertised origin and the origin the relay verifies against can never diverge.Operator API origin decoupling
RELAY_OPERATOR_API_ORIGINis no longer required at boot whenRELAY_OPERATOR_PUBKEYSis set — it is used only by the community-provisioning endpoints, which fail closed at request time (with a boot-timeWARN) until it is set. The admin console needs no origin.Admin-web adaptation
The standalone
admin-webdashboard signs each request as a NIP-98 event via a NIP-07 browser extension, discovers the auth mode with a single unauthenticated probe (200→disabled, anything else →nip98, fail-secure), and carries no token entry surface. Playwright coverage exercises the NIP-98 and CSP paths.Security hardening
Three findings from security review are folded in:
PUT/DELETE /operators/{pubkey}mutate the deployment-wide root of trust, but the upsert overwritesrole/added_byin place and the delete removes the only row — so a grant→revoke sequence left no trace of who was ever granted or by whom. Each mutation now writes anrelay_operator_auditrow (actor, target,grant/revoke, pre-imageprev_role,new_role, timestamp) inside the same transaction as the mutation. A per-target transaction-scoped advisory lock serializes concurrent mutations of the same pubkey before the pre-image read, so the recordedprev_roleis always the true predecessor even under a concurrent-grant race. Chronology is keyed on aBIGINT GENERATED ALWAYS AS IDENTITYseqcolumn, not the wall clock: the serializing lock guarantees insertion order andseqcaptures it, so ordered reads (ORDER BY seq) follow the true privilege chain even across a backward NTP step that aclock_timestamp()ordering would invert.created_at(clock_timestamp()) is retained as informational occurrence time only. Append-only by construction — noUPDATE/DELETEpath and no API surface. A no-op delete writes nothing.expirationSecsoverflow. The timeout path builtUtc::now() + Duration::seconds(secs as i64)from an attacker-controlledu64:i64::MAXpanicked the handler, and a wrapped-negative magnitude minted a past expiry that still passed validation.compute_timeout_untilnow rejects zero, rejects magnitudes above a documentedMAX_TIMEOUT_SECS(365 days), and uses checkedtry_seconds/checked_add_signedso no input can panic or produce a past expiry — over-cap, zero,i64::MAX, and wrapping-negative inputs all return a clean4xx.409immutability check raw-string-compared the path param whiledecode_hex_pubkeyaccepted uppercase — soPUT /operators/{UPPERCASE}skipped the guard and wrote a shadow row for the same 32 bytes. The validated param is now canonicalized (lowercased) before the409check, DB write,DELETE, and response body.Migrations
0035_relay_operators.sql—relay_operatorsroster table (deployment-global),actor_authorityonmoderation_actions,processingstatus plusactive_action_idonmoderation_reports,statusonproduct_feedback.0036_relay_admin_actions.sql— enforcement-action table with arequest_ididempotency key, astep_markerfor crash recovery, and acancelled_byattribution column.0037_relay_admin_action_lease.sql— lease fencing for the action worker.0038_relay_admin_outbox_claim_token.sql— fenced claim token on the outbox worker.0039_relay_operator_audit.sql— append-onlyrelay_operator_audittrail for roster mutations (see Security hardening).docs/admin/README.mddocuments the full principal model, NIP-98 event requirements, capabilities by role, the startup error matrix, and the discovery field.Production blast radius
A relay without
BUZZ_ADMIN_HOSTis completely unaffected — the admin surface stays disabled andBUZZ_ADMIN_AUTHis ignored; a lingeringBUZZ_ADMIN_TOKENlogs a startup warning and must be removed. WhereBUZZ_ADMIN_HOSTis set, unsetBUZZ_ADMIN_AUTHdefaults tonip98(per-person signed auth);BUZZ_ADMIN_AUTH=disabledreproducesmain's priorHost/Origin-only gating but is read-only (mutation routes403). The five migrations add tables and columns without touching existing data.Related: block/buzz#4768 (desktop admin console consuming the
admin_apifield), squareup/bb-public#339 (Phase 4 rollout config)