Skip to content

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

Merged
wpfleger96 merged 54 commits into
mainfrom
wpfleger/admin-api-bearer-auth
Aug 28, 2026
Merged

refactor(relay): NIP-98 admin auth with Operator/Moderator roles and NIP-11 discovery#3777
wpfleger96 merged 54 commits into
mainfrom
wpfleger/admin-api-bearer-auth

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 30, 2026

Copy link
Copy Markdown
Member

Adds authenticated, role-based moderation to the relay admin API. On main the admin API is read-only and gated only by Host/Origin matching; 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_AUTH accepts nip98 or disabled. Leaving it unset defaults to nip98 (fail-secure). Configuration fails closed: any other value aborts startup, while a lingering BUZZ_ADMIN_TOKEN is ignored with a startup warning — token (bearer) authentication is not supported. Host/Origin matching 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 a WARN on every boot. Always read-only: authorize() resolves no principal, so mutation and staffing routes always 403.

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)

Role Description
Operator Full control of the deployment's moderation surface: read all reports, feedback, and attachments across every community; resolve reports with enforcement (delete/kick/ban/timeout) or decisions (dismiss/escalate); reopen and cancel; update feedback status; and manage the Operator/Moderator roster via the staffing endpoints.
Moderator Day-to-day triage: everything an Operator can do except staffing — cannot view or change the roster.

How a pubkey acquires a relay role (resolution order; config always outranks DB):

  1. Listed in RELAY_OPERATOR_PUBKEYSOperator (source config)
  2. Equals RELAY_OWNER_PUBKEY while RELAY_OPERATOR_PUBKEYS is empty → Operator (source owner_fallback, a break-glass grant for self-hosters that deactivates once any operator is configured)
  3. Row in the relay_operators table → Operator or Moderator (source db, managed via the staffing endpoints)
  4. No match → 403

Community level (pre-existing, unchanged)

Role Description
Owner (community) Full authority within their community: every moderation action (delete, kick, ban/unban, timeout/untimeout, resolve reports, view queue) plus member, role, and invite management. No guard rails.
Admin (community) Same community-wide moderation capabilities as owner, except an admin cannot ban or time out the owner or a fellow admin — only the owner may action an admin. Manages members and invites; only the owner grants the admin role.
Member (community) Standard participant; no moderation capability.
Owner / Admin (channel) Channel-local authority only: delete messages and kick users within their own channel.
Member / Guest / Bot (channel) No moderation authority.

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:

  • Escalated-by-default listing. GET /reports with no status parameter returns only escalated reports. An explicit status=<open|resolved|dismissed|escalated> filter is always honored as given, and full visibility across every status stays available for platform-safety and legal review via scope=all (which lists reports regardless of status). scope accepts only all and is ignored when an explicit status is present.
  • Auto-escalated illegal reports. Member reports whose category is illegal are ingested with status=escalated rather than open, so the severe class reaches the operator backstop without waiting for a community admin to forward it. Every other category still lands open. 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 to open on the same terms, keyed only on status, never on how the report became escalated.

Principal resolution and NIP-98 admission

resolve_admin_principal() returns AdminPrincipal { pubkey, role, source } per the resolution order above; None never 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}/resolve is 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 (openprocessing), 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's p tag) via a single derive_enforcement_target shared 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 stays open; delete needs only the event id and is exempt.

GET /reports/{id} and the resolve response carry an activeAction field surfacing the enforcement that actually executed — a report dismissed after a reopen still reports the ban that ran. POST /reports/{id}/reopen returns a terminal report to open (idempotent on requestId). POST /reports/{id}/cancel is the sole recovery path for a pre-mutation failed action, attributed via relay_admin_actions.cancelled_by.

Feedback

GET /feedback and /feedback/{id} survive a tenant purge: provenance columns are severed to NULL rather than cascade-deleted, and the attachment path fails closed to 404 on a severed row. PATCH /feedback/{id} updates lifecycle status (new/reviewed/archived).

Staffing and probe

GET/PUT/DELETE /operators/{pubkey} are Operator-only; mutating a config-backed pubkey returns 409 Conflict. GET /operators returns the union of config and DB principals with per-entry source. GET /probe reports auth mode, role, source, canAct, and canStaff for the desktop console.

NIP-11 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 BUZZ_ADMIN_HOST is set and omitted otherwise. The scheme follows the same loopback rule as NIP-98 u-tag verification via a shared scheme_for_host helper, so the advertised origin and the origin the relay verifies against can never diverge.

Operator API origin decoupling

RELAY_OPERATOR_API_ORIGIN is no longer required at boot when RELAY_OPERATOR_PUBKEYS is set — it is used only by the community-provisioning endpoints, which fail closed at request time (with a boot-time WARN) until it is set. The admin console needs no origin.

Admin-web adaptation

The standalone admin-web dashboard signs each request as a NIP-98 event via a NIP-07 browser extension, discovers the auth mode with a single unauthenticated probe (200disabled, 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:

  • Append-only roster audit. PUT/DELETE /operators/{pubkey} mutate the deployment-wide root of trust, but the upsert overwrites role/added_by in 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 an relay_operator_audit row (actor, target, grant/revoke, pre-image prev_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 recorded prev_role is always the true predecessor even under a concurrent-grant race. Chronology is keyed on a BIGINT GENERATED ALWAYS AS IDENTITY seq column, not the wall clock: the serializing lock guarantees insertion order and seq captures it, so ordered reads (ORDER BY seq) follow the true privilege chain even across a backward NTP step that a clock_timestamp() ordering would invert. created_at (clock_timestamp()) is retained as informational occurrence time only. Append-only by construction — no UPDATE/DELETE path and no API surface. A no-op delete writes nothing.
  • expirationSecs overflow. The timeout path built Utc::now() + Duration::seconds(secs as i64) from an attacker-controlled u64: i64::MAX panicked the handler, and a wrapped-negative magnitude minted a past expiry that still passed validation. compute_timeout_until now rejects zero, rejects magnitudes above a documented MAX_TIMEOUT_SECS (365 days), and uses checked try_seconds/checked_add_signed so no input can panic or produce a past expiry — over-cap, zero, i64::MAX, and wrapping-negative inputs all return a clean 4xx.
  • Uppercase-hex config-backed bypass. Config pubkeys are lowercased at parse, but the 409 immutability check raw-string-compared the path param while decode_hex_pubkey accepted uppercase — so PUT /operators/{UPPERCASE} skipped the guard and wrote a shadow row for the same 32 bytes. The validated param is now canonicalized (lowercased) before the 409 check, DB write, DELETE, and response body.

Migrations

  • 0035_relay_operators.sqlrelay_operators roster table (deployment-global), actor_authority on moderation_actions, processing status plus active_action_id on moderation_reports, status on product_feedback.
  • 0036_relay_admin_actions.sql — enforcement-action table with a request_id idempotency key, a step_marker for crash recovery, and a cancelled_by attribution 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-only relay_operator_audit trail for roster mutations (see Security hardening).

docs/admin/README.md documents 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_HOST is completely unaffected — the admin surface stays disabled and BUZZ_ADMIN_AUTH is ignored; a lingering BUZZ_ADMIN_TOKEN logs a startup warning and must be removed. Where BUZZ_ADMIN_HOST is set, unset BUZZ_ADMIN_AUTH defaults to nip98 (per-person signed auth); BUZZ_ADMIN_AUTH=disabled reproduces main's prior Host/Origin-only gating but is read-only (mutation routes 403). The five migrations add tables and columns without touching existing data.


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
@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
@wpfleger96
wpfleger96 force-pushed the wpfleger/admin-api-bearer-auth branch 2 times, most recently from 6d893a5 to 02aba40 Compare August 12, 2026 18:24
wpfleger96 added a commit that referenced this pull request Aug 13, 2026
…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>
@wpfleger96
wpfleger96 force-pushed the wpfleger/admin-api-bearer-auth branch 3 times, most recently from 1e0b013 to 0af7a91 Compare August 19, 2026 17:55
Duncan and others added 6 commits August 20, 2026 11:43
…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
@wpfleger96
wpfleger96 force-pushed the wpfleger/admin-api-bearer-auth branch from d434742 to 31551f2 Compare August 27, 2026 15:43

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Two blockers remain at exact head 31551f2e566ea6944b1fa6fb56f734605cae06c8:

  1. Do not navigate attacker-controlled attachment bytes as typed blob documents. Feedback imeta supplies the MIME and filename shown by the admin SPA. The authenticated attachment fetch preserves the media response type, requestObjectUrl turns it into a blob URL, and both the image and generic-file paths expose that URL through target="_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 as application/octet-stream, and inline-render only server-verified passive image formats. Add regressions for active MIME/extension combinations.

  2. Preserve at least one effective Operator across roster mutations. PUT /operators/{pubkey} and DELETE /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.

Duncan and others added 3 commits August 27, 2026 13:32
…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>
Duncan and others added 2 commits August 27, 2026 15:26
…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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Duncan and others added 2 commits August 27, 2026 19:31
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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@wpfleger96
wpfleger96 merged commit 86b9142 into main Aug 28, 2026
38 of 39 checks passed
@wpfleger96
wpfleger96 deleted the wpfleger/admin-api-bearer-auth branch August 28, 2026 15:43
wpfleger96 pushed a commit that referenced this pull request Aug 28, 2026
…-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>
wesbillman pushed a commit that referenced this pull request Aug 28, 2026
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>
wpfleger96 added a commit that referenced this pull request Aug 28, 2026
…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>
wpfleger96 pushed a commit that referenced this pull request Aug 28, 2026
…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>
delkc added a commit that referenced this pull request Aug 28, 2026
…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>
wpfleger96 pushed a commit that referenced this pull request Aug 28, 2026
…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>
wpfleger96 pushed a commit that referenced this pull request Aug 28, 2026
…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>
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.

5 participants