Files
buzz/docs/community-auth-upgrades.md
Brad Seiler 2cd40badb8 Manage community NIP-FI policy with enable, disable and status (#9176)
## Summary

Operators can enable, disable and inspect a community's stored NIP-FI
requirement through signed POST/GET
`/operator/communities/nip-fi-policy` and `buzz-admin nip-fi-policy`.
This replaces the old enable-only endpoint and drops the
`nip-fi-upgrade` command added earlier in this PR, along with obsolete
compatibility code.

Each saved intent binds its desired state and observed generation/state.
Every accepted new intent advances the generation, including same-state
changes. Committed retries recover history without reapplying policy;
stale requests are refused. Current status exposes one pair of reusable
`expected_*` preconditions. Historical receipts remain readable after
policy changes or target deletion, including old rows with null
preconditions. Their UUIDs cannot be reused for new requests.

Migration 0085 stays additive; main’s default-channel recovery migration
retains version 0084. It preserves old receipt rows and INSERT
compatibility. Operator instructions cover use, recovery and client
migration: [usage
guide](33a1c0bb87/docs/operator-nip-fi-upgrade.md),
[client
contract](33a1c0bb87/docs/operator-nip-fi-policy-client.md).

### Rollout and containment

The old `/operator/communities/nip-fi-upgrade` route is removed; GET and
POST return 404 without its old auth handler. A pending sponsorship
client still targets that route. Migrate that client to the supported
API and deploy it with mutation dispatch paused before this relay
version reaches its fleet. Its complete CAS preconditions must be
durably saved before first dispatch and reused unchanged after a crash
or lost response. This is a pending integration dependency, not a claim
that the caller is deployed.

Close `BUZZ_NIP_FI_UPGRADES_ENABLED` on every old relay. Keep
`BUZZ_NIP_FI_CHANGES=false` on new relays. Apply schema first and finish
the whole-fleet rollout before opening the new gate and resuming
migrated clients. Before opening the new gate, verify the deployed
artifact SHA on every pod and probe each pod directly: the old route
must return 404, and a fresh, well-formed, signed policy request with a
new UUID must return 503 `upgrades_disabled`. A committed retry is not a
gate probe. Old pods with the old gate open can still accept unfenced
enables. Do not roll back to those writers after a policy operation. The
old variable is no longer read by this version; the new gate defaults
off and controls both directions.

Read accepted intent in `receipt`, current preconditions/effective
policy through status, and effective discovery through `/info`. Enable
stays `pending` because receipts cannot certify fleet-wide socket
closure. Under global `enforce`, stored disable leaves FI effectively
required. Closing the gate preserves receipt recovery; use a fresh CAS
disable or existing archive controls for containment. Preserve uncertain
requests and their UUIDs. This PR performs no live state change or
deployment.

### Related issue

N/A

### Testing

Published repair head: `33a1c0bb87a6fa10212eb4ec60788e24189627e2`,
incorporating main `39d5159caf`. The Git policy guards now apply only to
matched paths. Unknown GET/POST paths on relays without a web directory
return 404 instead of inheriting the internal endpoint’s 403. The
matched internal endpoint retains its localhost restriction and 1 MB
body limit, including its method fallback. The signed removed-route 404
assertion is unchanged.

Main’s migration 0084 is unchanged; the additive policy migration is now
0085 with identical SQL. Its populated-receipt upgrade test starts at
schema 84. Formatting and commit hooks pass; independent source review
has no findings. At this exact head, formatting, all-target Clippy (`-D
warnings`, relay/db/admin/pubsub), and test compilation (relay/db/admin,
`--lib --tests --no-run`) pass with exit 0. The full native PostgreSQL
lane passes: 1,373/1,373 tests, exit 0, including the unchanged signed
removed-route 404 assertion, fresh-database migration and populated
receipt upgrade from main schema 84 to 85. The first full package run
used an uninitialized private database and stopped with two
missing-table errors; that failed attempt is retained. After schema
initialization and verification, the corrected full package rerun
passes: 2,041/2,041 tests, exit 0 (1,396 skipped by unit selection). The
separate PostgreSQL lane skips 2,064 tests by its selection. Both full
runs use the exact published repair head. Hosted CI remains pending. The
new router test reproduced the old failure with the exact localhost-only
response body; the new production-router regression also passes in the
corrected package run. Four individual guard mutations are running
serially; their assertion-failure receipts and the restored final green
run remain pending.

Brad authorized one `git push --no-verify` of this repair conditional on
passing lint and compilation. Those gates passed; the push exited 0 and
both the remote branch and GitHub PR SHA were verified as
`33a1c0bb87a6fa10212eb4ec60788e24189627e2`. The normal publication
attempt was stopped before completion and its log is retained. This
exception leaves remaining tests and acceptance requirements
outstanding.

Historical evidence below applies to the earlier published head, not the
published repair.

Published head: `d31793ccbcdfd748361bcd559358915862b5ed57`. This
integrates main's missing `ChannelEvent.received_at` fixture correction
and corrects the newly integrated initial-policy test to use
`nip_fi_changes`, with matching documentation. Independent source review
has no open findings. At this exact head, all-target Clippy with `-D
warnings` passes for buzz-relay, buzz-db, buzz-admin and buzz-pubsub;
independent Clippy and formatting checks also passed.

The normal push hooks failed (push exit 1):
- Rust: `buzz-acp` recovery test hit its 15-second test deadline.
Nextest completed 844/1101 tests: 843 passed, 1 failed, 3 skipped; 257
were not run.
- Tauri: `buzz-terminal` login-shell test received no child output
within its 10-second test deadline; 26 passed, 1 failed.
- Mobile: gallery test `2 trailing images match carousel at 208.0, scale
1.0` expected no loading placeholders and found two; 4,524 passed, 60
skipped, 1 failed.

These are two test-level deadline failures and one assertion failure,
not compiler errors. The failing source paths are unchanged from main
`6c3ffd160b`; no baseline reproduction or flaky classification is
claimed. Desktop JS checks/tests/typecheck, branch-skew and file-size
checks passed. Full hooks are not reported as passing. The first hosted
PostgreSQL run at d31793cc failed the removed-route assertion (expected
404, got 403); the router repair above addresses that failure. That
hosted run’s Rust unit, Tauri and mobile jobs passed.

Historical d31793cc publication exception: Brad explicitly authorized a
single `git push --no-verify` for this head on 2026-11-06 after the
failed normal-hook run. That push exited 0 and its remote SHA was
verified. Failed-hook evidence is retained. The earlier `cf2c9ba599`
publication also used an explicit exception. Neither exception waives
outstanding validation, review or human acceptance before merge.

Historical evidence at `5b2eb6f09366b8a8f480a5fc61e8e5368f62302d`: 2,006
affected-package tests and 32 disposable-relay checks passed. These
predate the latest main merge and are not final-head evidence. Earlier
native PostgreSQL and separate `just ci` / `just test` attempts were
incomplete, interrupted or failed and are not passing results.

No staging run or human runtime confirmation is claimed. This PR is not
a draft and is ready for review; readiness does not attest human runtime
confirmation.

Generated with Codex

---------

Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
2026-11-06 22:07:31 +00:00

8.7 KiB

Community NIP-FI policy operator contract

The operator plane manages a stored community requirement independently of the global FI mode. See operator usage. Receipts prove durable acceptance, never fleet-wide socket closure or current authorization.

Operator boundary

POST/GET /operator/communities/nip-fi-policy require an allowlisted operator's NIP-98 signature bound to RELAY_OPERATOR_API_ORIGIN, method and URL (including the GET query), and POST body hash. Each attempt needs a fresh signature. These routes extend provisioning outside tenant event ingest; ordinary membership credentials cannot invoke them. All responses use Cache-Control: no-store.

The operator mediates an authenticated actor's registered key. The relay verifies the operator and stored owner; actor_pubkey is the mediator's claimed actor, not an independent user credential. Never accept a caller-selected actor as identity.

Generation storage

communities.requires_nip_fi defaults false; policy_generation defaults zero. Every new general operation advances the generation, even a same-state intent. Replay never increments. Generation exhaustion is refused. The historical community_auth_upgrades table stores acceptance receipts for both directions. Receipts survive target deletion and are not tenant serving data.

Enable and disable

POST contains operation_id, community_id, canonical expected_host, actor_pubkey, expected_owner_pubkey, desired requires_nip_fi, and mandatory expected_policy_generation plus expected_requires_nip_fi. UUIDs must be non-nil, keys lowercase hex, generations nonnegative; unknown fields are rejected. Enable requires expected_issuer; disable must omit it.

Read current preconditions with GET ?community_id=<UUID>&expected_host=<host>. Both generation and stored state must still match under the writer row lock. A mismatch returns 409 policy_conflict without mutation or receipt. Re-read and use a new UUID/request for revised intent. On a lost response, preserve the original complete file/UUID and recover or retry it with a fresh signature.

Acceptance serializes UUID reuse, then locks deletion-shared → community FOR NO KEY UPDATE → exclusive host lease, preserving lifecycle/deletion-intent, exact host, single owner and actor==owner checks and community write admission. Acceptance and receipt commit atomically. UUID identity binds the full tuple, action, both preconditions and authenticated operator. Different meaning under a committed UUID returns 409 idempotency_conflict. Identical committed replay recovers history before mutable preflight and never writes policy.

Enable requires a single deployment-selected authorized issuer in BUZZ_NIP_FI_PROVISION_BINDING, an applicable registry policy and exact existing community binding. It locks the binding against unbind and adds no trust. Disable does not depend on issuer availability and does not alter bindings. Every optional→required transition, including re-enable, excludes new host leases and drains prior optional leases for 1.1 seconds before committing.

Readback and response

GET ?operation_id=<UUID> returns only the originating operator's receipt. Mixed query forms are invalid. The response separates receipt (original tuple, accepted generation/action/preconditions and current target generation/flag) from mode and effective_required. Current-state reads also print exact expected expected_policy_generation and expected_requires_nip_fi fields, without a duplicate unprefixed pair. Missing/deleted targets have null current fields.

Receipt state Meaning CLI exit
applied Current stored disable 0
pending Current accepted enable; closure uncertified 10
superseded Later generation or state differs 11
unavailable Target/lifecycle/host or required issuer trust unavailable 12

pending_reasons includes connection_closure_unverified for enable. Neither time, local counters nor a successful sweep certify fleet completion. No response reports active. History is not a current authorization credential; ownership changes do not destroy the original operator's recovery authority.

Invalid requests return 400; operator auth uses existing 401/403 errors. Missing community/operation returns 404. Host, owner, lifecycle, issuer and policy conflicts return 409 with corresponding *_conflict codes; generation exhaustion returns 409 policy_generation_exhausted. New mutations behind a closed gate return 503 upgrades_disabled; unavailable authoritative DB/trust returns 503 authorization_unavailable. Request/transport/response errors exit 5.

Schema and rollout

Migration 0085 adds desired action and nullable CAS preconditions to existing receipts. True/default and null preconditions preserve old receipt meaning and old INSERT compatibility. No data backfill, trigger, function or foreign key is added. Historical rows with null CAS preconditions remain readable through policy status, including after target deletion. A new fenced request cannot reuse such a row's UUID: null historical preconditions differ from the mandatory new preconditions, so it returns idempotency_conflict without mutation.

The old /operator/communities/nip-fi-upgrade endpoint and the nip-fi-upgrade admin command have been removed. Its GET and POST requests receive 404; use nip-fi-policy for both changes and receipt recovery. Upgrade existing clients for the policy client contract before this relay version reaches their fleet, with their mutations paused until rollout ends. No client may guess CAS fields or refresh a saved request during recovery.

Before rolling, close BUZZ_NIP_FI_UPGRADES_ENABLED on every old relay and keep BUZZ_NIP_FI_CHANGES=false on new relays. Apply schema first, roll the whole fleet to the supported policy writer, then explicitly enable the new gate and resume migrated clients. Old binaries with their old gate open can still accept unfenced enables and reverse a disable; a new-name setting alone cannot close that old gate. Retire the old setting after the last old binary is gone. Reads and committed replay remain available on the supported API with its gate closed. Do not roll back to a writer that exposes unfenced enable after any policy operation. This removal does not erase receipts or rewrite schema.

Request enforcement and discovery

off ignores stored requirements; shadow enforces stored true; enforce is a global requirement floor; deny_protected denies protected traffic. Report effective_required alongside stored state so disable under enforce and enable under off cannot be mistaken for effective changes. Evaluating policy lookups fail closed on errors; off does not require an FI policy read.

Host policy comes from the writer, bound to community identity and lifecycle. Optional cache leases last at most one second; enable excludes refills and drains them. Fail-closed cache ordering may retain old required state for the remaining lease after disable. It cannot renew that stale lease. /info advertises effective requires_nip_fi without exposing operator receipts.

Existing connections

The periodic revalidator reads stored policy without row-locking transactions. After disable it stops requesting required-policy closes; a concurrent sweep may request one last non-FI close from its earlier snapshot. That client reconnects under optional policy. FI-admitted sockets retain their finite lifetime and then reconnect under current policy. Disable does not reopen or force-logout sessions. Re-enable repeats the optional-lease drain; non-FI root/audio sockets close through existing revalidation. Valid FI-admitted sockets retain existing rules.

Mistaken acceptance and containment

Stop an uncertain initiating caller and preserve its operation evidence. Read current policy before deciding whether to issue a new explicit CAS disable. A timeout is not permission to discard the original request or edit SQL. Disable is a supported operator action; acceptance is no longer monotonic. Disable removes the required-policy block on last-issuer removal; the separate last-binding guard still applies. The operator unbind command keeps the last issuer for bindable hosts. Re-enable must recheck trust and the exact binding. Removing trust never implicitly clears stored policy.

Close the mutation gate to stop new changes while keeping recovery available. Use established archive/lifecycle containment or repair FI trust as appropriate; never broaden trust to make a probe pass. Global off suspends FI everywhere while preserving stored policy and receipts; it is not per-community disable. Binary rollback does not undo acceptance and must not restore unfenced writers. No live mutation or deployment is implied by this procedure.