Files
buzz/docs/community-auth-upgrades.md

7.9 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, request form 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 precondition fields. 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 0084 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. Apply schema first, then compatible management writers across the whole fleet with BUZZ_NIP_FI_UPGRADES_ENABLED=false, then explicitly enable mutations. The gate controls both directions; reads and committed replay remain available. Do not roll back to an unfenced management writer after the first general operation.

Legacy /nip-fi-upgrade remains enable-only. Fresh enables on optional/nonzero generation are refused, so an old uncommitted retry cannot reverse disable. Already-required legacy no-ops retain their generation. Legacy GET hides general CAS receipts with 404, and POST cannot reuse their UUIDs. Old binaries cannot hide those receipts or fence retries; the gate-off whole-fleet rollout is mandatory. General status can read legacy receipts, with null CAS preconditions.

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. Last-issuer removal is allowed after disable, and 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.