Files
buzz/docs/operator-nip-fi-bindings.md
Will Pfleger 6286a54321 feat(relay): store per-community NIP-FI issuer bindings (#8372)
Stack: this PR → #8377 → #8399 → #8401

bb-public creates communities all the time, so a hand-edited
`BUZZ_NIP_FI_COMMUNITIES` map can't list them. This PR stores an
explicit NIP-FI issuer allowlist for each community in the database and
writes it when the community is created. Login checks don't read the
table yet; that comes in #8377 together with the database-backed
resolver. Operator steps are in
[`docs/operator-nip-fi-bindings.md`](docs/operator-nip-fi-bindings.md).

**Storage.** Migration `0066_community_nip_fi_issuers` adds
`community_nip_fi_issuers (community_id, issuer, created_at)`, with
primary key `(community_id, issuer)` and the community write fence
attached. The assertion audience is `https://<communities.host>`, so the
table has no `aud` column. Zero rows means no binding. The table is
included in whole-community deletion and purge.

**One bindable rule.** `BUZZ_NIP_FI_PROVISION_BINDING` (`{"host_suffix":
..., "authorized_issuers": [...]}`) is parsed in `buzz-auth` with
`BUZZ_NIP_FI_ISSUERS`, whatever `BUZZ_NIP_FI_MODE` is set to, and both
the relay and `buzz-admin` use the same parser. Each authorized issuer
must be a configured issuer (1 to 2048 bytes), and the suffix must not
cover any host declared in `BUZZ_NIP_FI_COMMUNITIES`; an unreadable
declaration refuses startup even in mode `off`.
`ProvisionBindingPolicy::is_bindable(host)` is true for a host exactly
one label under the suffix that is not reserved. It decides
creation-time binding, explicit `bind`, and what the lookup route
reports.

**Binding on creation.** Rows are written in the same transaction as the
community insert on all three creation paths (`POST
/operator/communities` `create_only`, its legacy convergence branch, and
startup bootstrap), and only when the call actually created the
community, so existing rows are never rewritten.

**Reserved host.** A host whose `https://<host>` equals a configured
`command_audiences` value never holds a binding. Operator provisioning
of it is rejected with a 400 and the `reserved_host` error code,
bootstrap creates it unbound, and the availability route reports it
unavailable with an error code. `main.rs` calls
`verify_reserved_hosts_unbound` at startup; if a reserved host has rows
the relay refuses to start and names the fix (`buzz-admin nip-fi unbind
--host <h> --issuer <i>`, or revert the command audience). A database
failure during that check is a separate `Check` error.

**Operator commands.**
- `buzz-admin nip-fi backfill` pages unbound communities by host (1000
per page) and binds each page with one `INSERT ... SELECT` limited to
active communities. It skips communities being deleted, always prints
`{rows_bound, unbound, unbound_hosts, truncated}`, and exits non-zero
while any bindable community is still unbound. Archived communities are
bound on purpose. Both bind paths take the shared deletion lock and then
lock the community row (`FOR NO KEY UPDATE`, in id order for a batch),
the same order deletion uses, and the backfill insert also requires the
community to still have no binding, so an operator `bind` that lands
after the backfill listed a community wins and the default issuer is not
added beside it.
- `buzz-admin nip-fi bind --host --issuer` refuses a reserved host, a
host outside the suffix, or an unconfigured issuer.
- `buzz-admin nip-fi unbind --host --issuer` refuses to remove a
community's last issuer (archive or delete the community instead), with
one exception: when a valid provisioning policy shows that backfill
can't bind the host (it's reserved, or outside the suffix), the last
issuer can be removed. That is the reserved-host recovery path in the
runbook. A missing or malformed policy doesn't block `unbind`; it just
means the exception never applies.

**Lookup route.** `GET /operator/communities/nip-fi-binding?host=`
(NIP-98, `RELAY_OPERATOR_PUBKEYS`) reads the database and returns
`{host, active, issuers[]}`. `host` is the stored host. It returns 404
for an unknown host, 200 with `active:false` for an archived or
quiescing community, and `issuers:[]` when no provisioning policy is
set. It lists only issuers that are configured and on a bindable host.
kgoose depends on this contract; it is also documented in the runbook.

**NIP-FA.** The `relay_url` bullet now also says: "An adapter MAY obtain
its exact relay list from a configured, authenticated source; it MUST
still compare `relay_url` exactly."

**Deployment dependency.** The kgoose config side is already handled by
[cash-server#137671](https://github.com/squareup/cash-server/pull/137671).
In public-community mode it requires the command audience not to be a
community audience.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-17 13:13:49 -04:00

4.2 KiB

NIP-FI Community Issuer Bindings

A deployment that provisions communities under one host suffix, for example <name>.communities.buzz.xyz, stores the NIP-FI issuers allowed to vouch for each community in community_nip_fi_issuers. The relay writes these rows when it creates a community; buzz-admin nip-fi backfills and edits them.

A host may hold a binding only when it is exactly one DNS label under the suffix, has no port, and is not reserved. A host is reserved when https://<host> is a command_audiences entry of any issuer in BUZZ_NIP_FI_ISSUERS, so a command audience can never become a community assertion audience. The relay refuses to start while a reserved host holds a binding, and the operator lookup route reports no issuers for a host that is not bindable or for an issuer that is not configured.

Configuration

BUZZ_NIP_FI_PROVISION_BINDING is read in every NIP-FI mode. Unset or empty binds nothing.

{
  "host_suffix": "communities.buzz.xyz",
  "authorized_issuers": ["https://app.builderlab.xyz/api/goose"]
}
  • host_suffix is a lowercase domain name without a port or trailing dot.
  • authorized_issuers is non-empty, and every entry must be an issuer in BUZZ_NIP_FI_ISSUERS. Each issuer is 1 to 2048 bytes.
  • The suffix must not cover any BUZZ_NIP_FI_COMMUNITIES host, and every canonical_uri there must be readable. The relay and buzz-admin both refuse to start otherwise.

buzz-admin parses the same variables with the relay's parser. Run it with the relay's environment: BUZZ_NIP_FI_PROVISION_BINDING, BUZZ_NIP_FI_ISSUERS, BUZZ_NIP_FI_COMMUNITIES, and DATABASE_URL.

Commands

buzz-admin nip-fi backfill
buzz-admin nip-fi bind --host acme.communities.buzz.xyz --issuer https://issuer.example
buzz-admin nip-fi unbind --host acme.communities.buzz.xyz --issuer https://issuer.example

backfill binds the authorized issuers to every active, unbound, bindable community, a page of 1000 hosts at a time. Archived communities are bound on purpose, so unarchiving leaves a community that can log in. A community whose deletion has started is skipped. It always prints its counts and exits 1 while any bindable community remains unbound:

{"rows_bound": 12, "unbound": 0, "unbound_hosts": [], "truncated": false}

unbound_hosts lists at most 100 hosts; truncated is true when there are more.

bind refuses a reserved host, a host that is not one label under the suffix, and an issuer not in BUZZ_NIP_FI_ISSUERS.

unbind works without BUZZ_NIP_FI_PROVISION_BINDING. It refuses to remove a community's last issuer, because an empty community looks never-provisioned and the next backfill would bind it again. To swap issuers, bind the new one and then unbind the old one. To stop a community logging in, archive or delete it. When the provisioning config is present and shows the host is not bindable (a reserved host or one outside the suffix), unbind removes the last issuer too; this is how to clear a reserved-host binding that blocks relay startup.

backfill never adds the default issuers beside an operator bind: both lock the community row, and the backfill only inserts while the community still has no binding.

Lookup route

GET /operator/communities/nip-fi-binding?host=<host> is operator-signed and returns {"host": ..., "active": ..., "issuers": [...]}. kgoose relies on this contract:

  • host is the host stored on the community row.
  • Every lifecycle state answers 200. active is false once the community is archived or its deletion has started quiescing, including a tombstoned community; it stays true while a deletion is only requested.
  • issuers is [] when BUZZ_NIP_FI_PROVISION_BINDING is unset, the host is not bindable, or nothing is bound. Only issuers in BUZZ_NIP_FI_ISSUERS are reported, in byte order.
  • 404 means no community has this host; 400 means the host is malformed.

Rollout

  1. Deploy the relay with BUZZ_NIP_FI_PROVISION_BINDING set. New communities are bound at creation from then on.
  2. Run buzz-admin nip-fi backfill with the relay's environment. Repeat it until it exits 0.
  3. Do not turn on the database binding source in enforce mode while any bindable community is unbound: an unbound community fails closed.