feat(relay): NIP-98 admin auth with Operator/Moderator roles and NIP-11 discovery - #3777
Open
wpfleger96 wants to merge 15 commits into
Open
feat(relay): NIP-98 admin auth with Operator/Moderator roles and NIP-11 discovery#3777wpfleger96 wants to merge 15 commits into
wpfleger96 wants to merge 15 commits into
Conversation
wpfleger96
force-pushed
the
wpfleger/admin-api-bearer-auth
branch
3 times, most recently
from
July 31, 2026 19:17
3fcbdc0 to
d014e40
Compare
kalvinnchau
previously approved these changes
Jul 31, 2026
kalvinnchau
left a comment
Contributor
There was a problem hiding this comment.
Re-reviewed at d014e40. The fail-closed config contract, constant-time bearer validation, host/origin ordering, insecure network-boundary mode, dashboard token lifecycle, authenticated attachment fetches, and CSP/static routing are coherent and covered. Deployment dependency is external: land bb-public#339 and wait for Argo rollout before deploying this relay image.
wpfleger96
force-pushed
the
wpfleger/admin-api-bearer-auth
branch
from
August 3, 2026 19:40
d014e40 to
e93d5be
Compare
wpfleger96
force-pushed
the
wpfleger/admin-api-bearer-auth
branch
2 times, most recently
from
August 3, 2026 20:22
9d54f68 to
1682a5e
Compare
wpfleger96
force-pushed
the
wpfleger/admin-api-bearer-auth
branch
from
August 11, 2026 00:19
5527704 to
1cdc816
Compare
wpfleger96
force-pushed
the
wpfleger/admin-api-bearer-auth
branch
2 times, most recently
from
August 12, 2026 18:24
6d893a5 to
02aba40
Compare
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>
…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>
wpfleger96
force-pushed
the
wpfleger/admin-api-bearer-auth
branch
from
August 13, 2026 06:47
02aba40 to
3339cf9
Compare
… 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>
Integrate main (2693e0d, workflow run history). Resolve the migration-number collision: main claimed 0031_workflow_run_error_codes, so this branch's admin-auth migrations renumber 0031-0034 -> 0032-0035 and the migration.rs registry re-sequences (count 35, relay-admin index asserts shifted +1). schema.sql carries both changes. 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>
…arer-auth * origin/main: (34 commits) fix(ci): read Playwright version without nested shell quoting (#5910) fix(desktop): restore the agent trading-card mint button (#5900) Projects v3: unify sharing, discussions, and issue ownership (#5792) chore(release): release Buzz Desktop version 0.5.12 (#5903) fix(mobile): unwrap batched observer telemetry (#5805) perf(desktop): update active turns incrementally (#5897) fix(link-previews): send while previews finish in background (#5697) fix(desktop): cut steady-state relay traffic from polls and read-state echo (#5879) fix(desktop): support channel message path links (#5889) feat(mobile-messages): render compact Buzz permalink chips (#5639) test(desktop): await channel E2E bridge readiness (#5886) fix(link-preview): refetch a link when it re-enters the composer (#5510) feat(desktop-messages): render compact Buzz permalink chips (#5638) Fix video comment effect wrapping (#5748) Teach agents to inherit Buzz product intent (#5875) feat(desktop): one relative date ladder across chat and the Inbox (#3769) fix(desktop): amortize observer journal eviction with a low-water mark (#5808) Unify agent profile content (#5788) Standardize settings section layout (#5855) fix(desktop): share one timer across same-interval useNow consumers (#5861) ... Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> # Conflicts: # CHANGELOG.md
…arer-auth * origin/main: chore(release): release Buzz Desktop version 0.5.13 (#5912) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> # Conflicts: # CHANGELOG.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 configurable authentication (BUZZ_ADMIN_AUTH), a two-tier Operator/Moderator principal model backed by a config-union-plus-DB roster and NIP-98 HTTP auth, mutation and staffing endpoints, and NIP-11 auto-discovery so clients never type the admin URL by hand.Authentication (
BUZZ_ADMIN_AUTH)Introduces
BUZZ_ADMIN_AUTH, acceptingtoken(default),disabled, ornip98. Onmainthe admin API authenticates only byHost/Originmatching with no configurable mode; this branch adds explicit authentication that fails closed on missing or malformed configuration — any unrecognized value or conflicting combination aborts startup.Host/Originmatching is retained in all modes as defense-in-depth.Principal resolution (NIP-98 mode)
resolve_admin_principal()returnsAdminPrincipal { pubkey, role, source }:Operator/Config— pubkey is inRELAY_OPERATOR_PUBKEYSOperator/OwnerFallback— pubkey equalsRELAY_OWNER_PUBKEYandRELAY_OPERATOR_PUBKEYSis empty (config-evaluated, never from runtime DB rows)OperatororModerator/Db— row in therelay_operatorstable403Config always outranks DB, and
Nonenever falls through as a role. A malformedRELAY_OWNER_PUBKEYalongsidenip98is a startup error, since the owner key can be a break-glass root and silently discarding it would be a lockout.NIP-98 admission is ordered so the replay guard is a privilege, not a public surface:
authorize_nip98verifies signature, URL, method, and payload hash and returns the pubkey and event id without claiming the replay slot;resolve_admin_principalruns the roster check next; only then doesclaim_nip98_replayatomically consume the deployment-scoped replay id. A validly-signing but unrostered key (any network-admitted client) is rejected at principal resolution and never allocates a replay slot. Redis failure remains fail-closed and the deployment-scoped key format is unchanged.Token and disabled modes
Read routes work in all modes. Mutation and staffing routes require
nip98; token and disabled modes receive403fromrequire_mutation_principal.disabledmode logs aWARNon every boot and relies entirely on network-layer controls.Report resolution, recovery, and enforcement provenance
POST /reports/{id}/resolveis an enforcement state machine with idempotency:dismiss/escalate): CAS open→terminal plus an audit row in one transaction.delete/kick/ban/timeout): claims the report (open→processing), runs the durable mutation, then finalizes toresolved. Crash-safe — re-drive resumes at the step marker and converges to exactly-one enforcement, fenced by a lease and a claim token on the outbox.Person-directed enforcement (
kick/ban/timeout) needs a target user, but anevent-kind report row stores only the reported event id — the reporter-suppliedptag is validation-shape only and never persisted. A singlederive_enforcement_targetoverlays the reported event's author (server-owned truth read from the stored events row, never the reporter's claim) as the target foreventreports, keeping the event id;pubkey/blobreports pass through unchanged. The HTTP driver and the crash-recovery worker both call it, so a stranded action always re-derives against the same target it claimed — no persisted target column required. A soft-deleted event still carries its author, so enforcement stays valid. When the reported event is wholly absent (purged before its author could be determined), a pre-claim guard rejects person-directed actions withInvalidAction, leaving the reportopenand unclaimed rather than stranding it;deleteneeds only the event id and is exempt.GET /reports/{id}and the/resolveresponse carry anactiveActionfield (AdminActionDto | null) derived via aLEFT JOIN LATERALoverrelay_admin_actions— matching the report'sactive_action_idor its terminalsucceededenforcement,ORDER BY created_at DESC, id DESC. This surfaces the enforcement that actually executed against a target, so a report dismissed after a reopen still reports the ban/kick/delete that ran — honest enforcement history rather than a null.reopenaudit rows are excluded from the join (action IN ('delete','kick','ban','timeout')), and the DTO'sreason/expiresAt/errorMessageare required-nullable (always emitted,nullwhen absent).POST /reports/{id}/reopenreturns a terminal report (resolved/dismissed/escalated) toopenand records a durablereopenaudit row. Idempotent onrequestId— a retry returns the same success without re-reopening a report that has since been re-resolved;409if the report is not terminal.POST /reports/{id}/cancelcancels a pre-mutationfailedenforcement action, returning the report toopen. The acting principal is persisted inrelay_admin_actions.cancelled_byand surfaced asAdminActionDto.cancelledBy, so cancel — the one mutation that would otherwise leave no actor trail whileBUZZ_AUDIT_ENABLED=false— is attributed like every other. Cancel is the only recovery path for a failed action — a composed client-side retry has a failure window and implies an atomicity the relay does not provide. The response embeds the just-cancelled action DTO as the last look at that record, since a subsequent detail read (report back toopen) servesactiveAction: null; a409means the action is no longer cancellable (already cancelled, superseded, or past the mutation point) — treat as "refresh detail".GET /feedbackand/feedback/{id}useLEFT JOIN communitieswith nullablecommunityId/communityHostso feedback survives a tenant purge that severs provenance (product_feedback.community_idsetNULL, not cascade-deleted). The attachment path fails closed to404on a severed row — no tenant to bind, and its media was purged with the community. Feedback DTOs also expose the lifecyclestatus.PATCH /feedback/{id}updatesproduct_feedback.status(new/reviewed/archived); requiresnip98.Staffing endpoints
GET/PUT/DELETE /operators/{pubkey}are Operator-only.PUT/DELETEagainst a config-backed pubkey returns409 Conflict.GET /operatorsreturns the union of config and DB principals with per-entrysourceattribution.Probe endpoint
GET /probereports auth mode, role, source,canAct, andcanStafffor the desktop console.NIP-11 admin-API auto-discovery
The NIP-11 relay-information document gains an optional
admin_apifield carrying the canonical admin origin (scheme://host[:port], no path), present iff the admin surface is configured (BUZZ_ADMIN_HOSTset) and omitted entirely otherwise. The scheme follows the same loopback rule as NIP-98u-tag verification (httpforlocalhost/127.x/::1, elsehttps), extracted into a sharedscheme_for_hosthelper so the advertised origin and the origin the relay verifies against can never diverge. Clients read this to auto-discover the admin console instead of requiring manual URL entry.Loopback example:
{ "admin_api": "http://127.0.0.1:3000" }Operator API origin decoupling
RELAY_OPERATOR_API_ORIGINis no longer required at boot whenRELAY_OPERATOR_PUBKEYSis set. The allowlist is shared by the NIP-98 admin console (which needs no origin) and the community-provisioning endpoints (which do). Configuring the admin console no longer drags in an origin the operator does not use: the relay logs aWARNnaming the affected feature, and the provisioning endpoints (POST /operator/communities) fail closed at request time —authorize_operator_requestrejects with a clean500before any replay or DB access — untilRELAY_OPERATOR_API_ORIGINis set.Admin-web adaptation
admin-webgains NIP-98 request signing via a NIP-07 browser extension, auth-mode discovery, and a token prompt fortokenmode, so the standalone dashboard authenticates under every new mode. Playwright coverage exercises the auth and CSP paths.Migrations
0032_relay_operators.sql—relay_operatorstable (deployment-global; registered in_operator_global_tables),actor_authorityonmoderation_actions,processingstatus plusactive_action_idonmoderation_reports,statusonproduct_feedback.0033_relay_admin_actions.sql—relay_admin_actionsenforcement-action table with arequest_ididempotency key, astep_markerfor crash recovery, and acancelled_byactor column attributing cancels.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.Documentation
docs/admin/README.mddocuments the full principal model, NIP-98 event requirements (query string, method tag, payload tag for body mutations), owner-fallback semantics, role/source table, capabilities by role, roster management, the startup error matrix, the NIP-11 auto-discovery field, and the origin decoupling..env.exampleanddeploy/compose/.env.exampledescribeRELAY_OPERATOR_API_ORIGINand the admin console/provisioning split.Related: block/buzz#4768 (desktop admin console consuming the
admin_apifield), squareup/bb-public#339 (Phase 4 rollout config)