mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
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>
98 lines
4.2 KiB
Markdown
98 lines
4.2 KiB
Markdown
# 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.
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```sh
|
|
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:
|
|
|
|
```json
|
|
{"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.
|