mirror of
https://github.com/block/buzz.git
synced 2026-11-11 20:01:09 +00:00
## 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 atd31793ccfailed 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. Historicald31793ccpublication 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>
258 lines
12 KiB
Markdown
258 lines
12 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.
|
|
|
|
To make an existing community require federated sign-in, use the separate
|
|
[community policy management command](operator-nip-fi-upgrade.md).
|
|
An issuer binding alone does not change that community's required policy.
|
|
|
|
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.
|
|
|
|
A community with durable `requires_nip_fi=true` always retains its last issuer.
|
|
The internal `allow_last` exception for unbindable hosts cannot override this
|
|
policy. Bind a replacement first, or archive/delete the community for containment.
|
|
Bulk backfill skips required communities whose bindings are missing; its closing
|
|
report still counts them as unbound. Repair those bindings with an explicit bind.
|
|
|
|
## 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`.
|
|
|
|
### Database community source
|
|
|
|
`BUZZ_NIP_FI_COMMUNITY_SOURCE=database` resolves communities from these
|
|
bindings instead of `BUZZ_NIP_FI_COMMUNITIES`. The relay refuses to start if
|
|
`BUZZ_NIP_FI_PROVISION_BINDING` is unset or `BUZZ_NIP_FI_COMMUNITIES` is set.
|
|
In `off` mode no snapshot is loaded.
|
|
|
|
| Variable | Default | Bound |
|
|
|----------|---------|-------|
|
|
| `BUZZ_NIP_FI_COMMUNITY_SOURCE` | `env` | `env` or `database` |
|
|
| `BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS` | 1,000,000 | 1 to 10,000,000 community-issuer rows; a larger load fails |
|
|
| `BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS` | 10,000 | under the 30s refresh interval |
|
|
| `BUZZ_NIP_FI_RESOLVER_NEGATIVE_CACHE_MAX` | 100,000 | 1 to 1,000,000 hosts |
|
|
| `BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT` | 8 | at least 1, clamped below the database pool size |
|
|
|
|
Each pod loads a snapshot before it binds any listener, and refreshes it
|
|
every 30s. A removed binding stops admitting new connections within 30s plus
|
|
the configured refresh timeout (40s by default) while refreshes succeed, and within two minutes of the last good load while they
|
|
fail. Open WebSocket sessions are not rechecked when a binding is removed;
|
|
they end at their own session deadline. Alert on
|
|
`time() - buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds > 120` and on
|
|
`absent(buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds)`.
|
|
|
|
## Initial policy on operator creation
|
|
|
|
A trusted NIP-98 operator may send `initial_requires_nip_fi: true` with
|
|
`create_only: true` to `POST /operator/communities`. The community, owner,
|
|
issuer bindings and required policy (generation 1) commit in one transaction.
|
|
Omitted, null or false creates an optional community at generation 0. A non-null
|
|
policy field without `create_only: true` returns 400
|
|
`initial_policy_requires_create_only`.
|
|
|
|
Required insertion needs an effective provision binding for the host; otherwise
|
|
400 `nip_fi_binding_required` leaves no row. Reserved hosts retain the existing
|
|
`reserved_host` refusal. The successful response includes the stored
|
|
`requires_nip_fi` and `status: "created"` for insertion or `"existed"` for a
|
|
same-owner replay. A replay changes neither policy nor bindings, including after
|
|
an operator disable or a change in issuer configuration. A different owner
|
|
retains the existing conflict. Legacy ensure and deployment bootstrap remain
|
|
optional and do not accept an explicit initial policy.
|
|
|
|
This policy is independent of `BUZZ_NIP_FI_CHANGES`. Mode `off` stores
|
|
it but bypasses enforcement. The production creation paths are operator
|
|
create-only, legacy operator ensure, and deployment bootstrap; only create-only
|
|
accepts this choice.
|
|
|
|
Upgrade the **whole serving relay fleet** before callers request true. Older
|
|
writers may ignore the unknown field and insert an optional row. Checking the
|
|
created response for stored true detects that incompatible writer, but cannot
|
|
undo its insertion; a later existing-row lookup remains read-only. Turn caller
|
|
targeting off before rolling back the relay. Turning it off affects future
|
|
insertions only; it does not clear existing policy.
|
|
|
|
## 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.
|
|
|
|
## Database source runbook
|
|
|
|
`BUZZ_NIP_FI_COMMUNITY_SOURCE=database` reads communities from these bindings
|
|
instead of `BUZZ_NIP_FI_COMMUNITIES`. Move one mode at a time, and only move
|
|
forward when the step's go signals hold.
|
|
|
|
### Off
|
|
|
|
Set `BUZZ_NIP_FI_COMMUNITY_SOURCE=database` with `BUZZ_NIP_FI_MODE=off`. No
|
|
snapshot loads in `off`, but every `BUZZ_NIP_FI_RESOLVER_*` and
|
|
`BUZZ_NIP_FI_SNAPSHOT_*` value is validated, so a bad one fails this deploy.
|
|
|
|
- Go: the rollout completes, and `buzz-admin nip-fi backfill` exits 0.
|
|
- No-go: a pod fails config validation. Fix the named variable.
|
|
|
|
### Shadow
|
|
|
|
Set `BUZZ_NIP_FI_MODE=shadow`. Each pod now loads the snapshot before any
|
|
listener binds, refreshes it every 30 s, and records what enforce would decide
|
|
without refusing anything. Shadow labels database-source records
|
|
`community="bound"` or `"unmapped"`, never a host.
|
|
|
|
- Go, held for at least a day of normal traffic:
|
|
- `time() - buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds` stays
|
|
under 60 on every pod.
|
|
- `buzz_nip_fi_binding_snapshot_load_failures_total` is flat.
|
|
- `buzz_nip_fi_binding_snapshot_rows / buzz_nip_fi_binding_snapshot_max_rows`
|
|
is under 0.5.
|
|
- `buzz_nip_fi_resolver_miss_rejections_total` is flat.
|
|
- The non-`admit` share of `buzz_nip_fi_shadow_total` is only what you expect
|
|
to deny; `community="unmapped"` comes only from hosts you do not serve.
|
|
- `stage="owner"` counts as would-deny: each is an agent enforce will refuse
|
|
for its token owner.
|
|
- No-go: any of these fails. Return to `off` and investigate.
|
|
|
|
### Enforce
|
|
|
|
Set `BUZZ_NIP_FI_MODE=enforce`. Watch the same signals, plus the rate of 503
|
|
responses on login.
|
|
|
|
- Go: the shadow signals keep holding, and 503s stay at their shadow baseline.
|
|
- No-go: logins return 503 or are refused unexpectedly. Set `mode=off` (below).
|
|
|
|
### Alerts
|
|
|
|
- Stale snapshot: `time() - buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds > 120`,
|
|
together with `absent(buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds)`
|
|
for a database-source pod.
|
|
- Near the row cap: `buzz_nip_fi_binding_snapshot_rows / buzz_nip_fi_binding_snapshot_max_rows > 0.8`.
|
|
`buzz_nip_fi_binding_snapshot_rows` is the size of the last good load.
|
|
- Over the cap or too slow: any increase of
|
|
`buzz_nip_fi_binding_snapshot_load_failures_total{reason=~"over_cap|timeout"}`.
|
|
- Live lookups shed: any increase of `buzz_nip_fi_resolver_miss_rejections_total`.
|
|
|
|
### When the snapshot is too big or too slow
|
|
|
|
A load fails when the snapshot has more rows than
|
|
`BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS` or takes longer than
|
|
`BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS`. Both get worse as communities are
|
|
added, so they usually arrive during normal growth, not at a deploy.
|
|
|
|
- New pods: the startup load fails and the process exits, so new pods
|
|
crash-loop and a rollout stalls. Boot already fails when the database is
|
|
unreachable; what this source adds is that snapshot size and load time can
|
|
also fail it.
|
|
- Running pods: their refreshes fail but the last good snapshot keeps
|
|
answering. Once it is 120 s old it is stale, and every login's host goes to
|
|
a live lookup. Live lookups share `BUZZ_NIP_FI_RESOLVER_MAX_INFLIGHT` slots
|
|
(8 by default). Concurrent logins for the same host share one lookup, and a
|
|
login waits up to 50 ms for a free slot; how many are shed depends on login
|
|
load across distinct hosts and on lookup latency. In enforce, a shed login
|
|
gets 503 (`authorization_unavailable`); in shadow it
|
|
is only recorded as a would-deny.
|
|
|
|
Ways out, in order:
|
|
|
|
1. Raise `BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS` (up to 10,000,000) or
|
|
`BUZZ_NIP_FI_SNAPSHOT_REFRESH_TIMEOUT_MS` (under 30,000), whichever the
|
|
failure `reason` names, and roll out.
|
|
2. If that cannot land quickly, set `BUZZ_NIP_FI_MODE=off`. `off` loads no
|
|
snapshot, so pods boot and admit as they did before NIP-FI.
|
|
|
|
## Assertion policy ID change
|
|
|
|
This release changes every verified assertion's policy ID once, on purpose.
|
|
The base ID (`derive_assertion_policy_id` in `crates/buzz-auth/src/nip_fi/config.rs`)
|
|
no longer hashes the configured community audiences. The ID carried on a
|
|
verified assertion is now `IssuerPolicy::id_for_community`: a hash of that base
|
|
and the one community audience the request resolved to. The issuer is still
|
|
committed through the base.
|
|
|
|
Policy IDs from earlier releases therefore do not match. Inside the relay the ID
|
|
is only carried on the verified assertion and never compared with a stored ID, so
|
|
admission is unaffected. Anything outside the relay that recorded old policy IDs,
|
|
such as dashboards, log queries or allow-lists, should expect new values after
|
|
the upgrade.
|