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

152 lines
8.7 KiB
Markdown

# Community NIP-FI policy operator contract
The operator plane manages a stored community requirement independently of the
global FI mode. See [operator usage](operator-nip-fi-upgrade.md). 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](operator-nip-fi-policy-client.md) 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.