Skip to content

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

Open
wpfleger96 wants to merge 15 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 15 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 an explicit authentication mode (BUZZ_ADMIN_AUTH), 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 token (default), nip98, or disabled. Configuration fails closed: any unrecognized value or conflicting combination (nip98 + BUZZ_ADMIN_TOKEN, token mode without a valid 64-hex BUZZ_ADMIN_TOKEN, a malformed RELAY_OWNER_PUBKEY alongside nip98) aborts startup. Host/Origin matching is retained in every mode as defense-in-depth.

  • token — bearer auth with a deployment-wide shared secret. Read-write when the relay has a stable identity (a configured BUZZ_RELAY_PRIVATE_KEY, or the deterministic dev key when BUZZ_REQUIRE_AUTH_TOKEN=false): mutations and staffing are attributed to the relay's own pubkey as Operator — the same identity that signs moderation notices, the honest actor for a shared deployment secret. Without a stable identity it stays read-only (mutations 403), preserving the never-NULL-actor invariant. Per-person attribution requires nip98.
  • nip98 — per-request signed NIP-98 (kind 27235) events, resolved to an Operator or Moderator principal with per-person attribution and individual revocability.
  • disabled — no credential; relies entirely on network-layer controls and logs a WARN on every boot. Read-only.

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

In token mode with a stable relay identity, the shared token synthesizes an Operator principal attributed to the relay's own pubkey (source relay_token) — no roster lookup; the deployment identity is the actor.

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.

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 gains NIP-98 signing via a NIP-07 browser extension, auth-mode discovery, and a token prompt for token mode, with Playwright coverage of the auth and CSP paths.

Migrations

  • 0032_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.
  • 0033_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.
  • 0034_relay_admin_action_lease.sql — lease fencing for the action worker.
  • 0035_relay_admin_outbox_claim_token.sql — fenced claim token on the outbox worker.

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 both BUZZ_ADMIN_AUTH and BUZZ_ADMIN_TOKEN are ignored. Where it is set, this is a breaking change: the relay now requires an explicit authentication choice at boot (unset BUZZ_ADMIN_AUTH defaults to token, which demands a valid BUZZ_ADMIN_TOKEN; BUZZ_ADMIN_AUTH=disabled reproduces main's prior Host/Origin-only gating), so the rollout config must land the env var alongside the image. In token mode, mutations are enabled and attributed to the relay identity when a stable relay key is present (production deployments already set one); without it, and in disabled mode, mutation routes return 403. The four 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 2 times, most recently from e7c9341 to 1e0b013 Compare August 18, 2026 20:59
Duncan and others added 7 commits August 19, 2026 13:22
…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>
…t DTO

The moderation console needs to reopen a terminally-resolved report, cancel a
failed enforcement action, and read the enforcement that ran against a target.
The prior surface exposed none of these: resolve returned an ad-hoc
`{status, actionId}`, report detail carried no action provenance, and feedback
from purged communities was silently dropped by an inner join.

- POST /reports/{id}/reopen returns a terminal report to `open` and records a
  durable `reopen` audit row; idempotent on request_id, 409 if not terminal.
- POST /reports/{id}/cancel cancels a pre-mutation `failed` action (the only
  recovery path — no composed client-side retry) and embeds the cancelled
  action DTO as the last look at a record a later detail read serves as null.
- GET /reports/{id} and /resolve now carry `activeAction`, derived via a LEFT
  JOIN LATERAL matching the report's active action or its succeeded enforcement
  (`ORDER BY created_at DESC, id DESC`), so a dismissed-after-reopen report
  still surfaces the enforcement that actually executed. reopen audit rows are
  excluded (`action IN ('delete','kick','ban','timeout')`).
- Feedback list/detail switch to LEFT JOIN communities with nullable
  communityId/communityHost so rows survive a tenant purge that severs
  provenance (product_feedback.community_id SET NULL); the attachment path
  fails closed to 404 on a severed row.

No migration added — activeAction is derived from existing indexes.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Duncan and others added 6 commits August 19, 2026 13:22
… 409)

cancel_action fenced only on id+state='failed'+step_marker IS NULL, with
no report/community constraint, and discarded the report-reopen row count.
POST /reports/A/cancel {actionId:B} cancelled B's action, stranded B as
processing with a terminal action, and returned a fabricated {status:"open"}
for A.

Make cancellation one atomic, ownership-fenced transition mirroring
finalize_success: the action UPDATE now also fences report_id +
report_community_id, the report UPDATE now also fences status='processing',
and both updates must each affect exactly one row or the whole transaction
rolls back to false -> 409 with zero state change. This is what makes the
handler's hard-coded "status":"open" legitimate.

Adds an HTTP->DB regression: two processing reports sharing a community,
each with a distinct failed action; /reports/A/cancel {actionId:B} must 409
and leave both reports and both actions byte-for-byte unchanged.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Person-directed enforcement (kick/ban/timeout) on an `event`-kind
moderation report had no target user: the report row stores only the
event id, and the reporter-supplied `p` tag is validation-shape only,
never persisted. HTTP resolution and the crash-recovery worker each
re-derive the target from the report + stored-event row, so both must
agree on who enforcement acts against.

Add `derive_enforcement_target` as the single source of truth: for
`event` reports it overlays the stored event's author (server-owned
truth from the events row) as the target pubkey, keeping the event id;
pubkey/blob reports pass through unchanged. The HTTP driver and the
recovery worker both call it, guaranteeing a stranded action re-derives
against the same target it claimed. A pre-claim guard rejects kick/ban/
timeout with InvalidAction when the target pubkey is unresolvable (event
purged or never accepted), leaving the report open and unclaimed rather
than stranding it.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The desired-state schema.sql had lagged the lease/claim-token migrations since they were authored: relay_admin_actions was missing action_lease_token/action_lease_expires_at and its lease index; relay_admin_outbox was missing attempt_count/retry_after/outbox_claim_token and still declared the pending index over the dropped lease_expires_at column. Bring desired state to the final 0035 shape and add a Postgres-backed parity test that bootstraps one probe DB from schema.sql, migrates another through 1-35, and asserts identical admin-table columns and index defs.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The idx_relay_admin_outbox_pending index declared retry_after NULLS FIRST in
both migration 0034 and schema.sql, but pgschema 1.7.4 (the real CI/test-relay
bootstrap path) silently discards per-key NULLS FIRST when it re-emits the
index, producing catalog indoption 0 0 while a fully-migrated database keeps
2 0. The desired-state bootstrap therefore diverged from the migration
contract, and the prior parity regression missed it because it applied
schema.sql via sqlx::raw_sql (which preserves NULLS FIRST) rather than through
bin/pgschema.

Drop NULLS FIRST from the index in both migration 0034 and schema.sql so both
paths converge on plain-ascending (retry_after, created_at). The claim query's
own ORDER BY retry_after NULLS FIRST, created_at ASC keeps the never-retried-
first semantics; Postgres applies that ordering to the small pending candidate
set regardless of the index's stored null ordering, and the partial predicate
is what makes the index selective. Migrations 0032-0035 are branch-local and
unshipped, so editing 0034 carries no checksum/brownfield risk.

Rewrite the parity regression to bootstrap the desired state through the real
bin/pgschema apply binary and assert per-key indoption (pg_index) alongside the
rendered indexdef, so a construct pgschema cannot represent can no longer pass.

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

Two defects from the kalvin-agent security review of the admin moderation API.

Replay-before-authorization: authorize_nip98 claimed the deployment-scoped
replay ID immediately after crypto verification, before resolve_admin_principal
ran the roster check. Any validly-signing but unrostered key (every
WARP-admitted laptop) could allocate replay slots at request rate. Split the
NIP-98 path into verify-only (authorize_nip98, returns pubkey + event id) and a
separate claim_nip98_replay called only after principal resolution succeeds, so
an unrostered signer never consumes a slot. Fail-closed Redis behavior and the
deployment-scoped key format are unchanged.

Cancel actor trail: cancel_report discarded the resolved principal and
cancel_action persisted nothing about who cancelled — the one mutation with no
actor attribution while BUZZ_AUDIT_ENABLED=false. Add a cancelled_by column to
relay_admin_actions (mirroring moderation_reports.resolved_by), stamped in the
cancel UPDATE and surfaced through AdminActionDto.cancelledBy. Migration 0033 is
branch-local and unshipped, so the column is added in place with matching
schema.sql; the pgschema parity test round-trips it through bin/pgschema.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Token-mode admin requests were read-only: mutations and staffing 403'd
because a shared secret names no actor and every enforcement action must
record a non-NULL actor pubkey.

When the relay has a stable identity (configured BUZZ_RELAY_PRIVATE_KEY, or
the deterministic dev key when BUZZ_REQUIRE_AUTH_TOKEN=false), the token arm
now synthesizes an Operator principal attributed to the relay's own pubkey
(AdminSource::RelayToken) — the same identity that signs moderation notices.
A shared token means someone held the deployment secret, so the honest audit
actor is the deployment identity, not a named person. Per-person attribution
still requires nip98. Disabled mode stays read-only.

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 1e0b013 to 0af7a91 Compare August 19, 2026 17:55
Duncan and others added 2 commits August 19, 2026 14:58
The token-mode dismiss acceptance test queried moderation_actions.action
= 'dismiss', but production writes 'dismiss_report' via
enforcement_audit_action(), so it failed RowNotFound against real
Postgres and never guarded the seam it named. Fix the query, fence by
the seeded report's community + target instead of latest-row, and assert
actor_authority = 'relay_operator' alongside actor_pubkey.

Add a positive staffing acceptance test (PUT records the relay key as
added_by, DELETE removes the row) so the full-Operator token privilege
is pinned end-to-end, and select both ignored tests in the
backend-integration Postgres CI lane — previously no api::admin ignored
test ran in CI, so a regression dropping mutation attribution could ship
green. Rename mounted_routes() to read_routes() to reflect that it only
enumerates the GET surface.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The dismiss acceptance test fenced its moderation_actions lookup on
community + action + a fixed target [1u8;32] shared by every
seed_admin_host_report() call. cleanup_admin_host_report deletes the
report but not its audit row, so on a reused database a prior run's
orphaned dismiss_report row could satisfy fetch_one() and the test would
read the wrong actor — order/state dependent, not a real fence.

Send a UUID-valued reason so the audit row carries a unique
public_reason, fence the lookup on it, and delete that audit row after
asserting. Both token-mode ignored tests now pass repeatedly against the
same Postgres database with zero rows left behind.

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