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>
Defines how an enterprise identity adapter issues assertions for agent
keys and how a NIP-FI relay admits them.
- **NIP-FA:** adapters issue agent-key assertions only under an
owner-authorized grant, created through the owner's own authentication;
revoking the owner invalidates every earlier grant. The "agent keys"
out-of-scope bullet is removed.
- **NIP-FI:** new `Agent keys` subsection under the assertion contract
(`nostr_owner_pubkey` claim, the verifier returns the owner, owner
recording for the session or request, ban/deny checks on both keys,
owner-derived authorization), `agent_assertions` discovery field, owner
deny-set check in WebSocket and HTTP admission, owner-matching
disconnect close scan, and the updated out-of-scope delegation lines.
---------
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
🤖 NIP-FA limited the adapter session token to the NIP's own endpoints,
which stopped a client from reusing the same login for the adapter
operator's other APIs on that host. This changes the rule to the
adapter's origin (the scheme, host, and port of `{adapter_base}`). The
token still never goes to a relay or any other origin, and the rest of
the security bullet is unchanged.
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Alia <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz>
🤖 Moves the enterprise identity adapter's wire contract into a new
normative spec, `docs/nips/NIP-FA.md` (Federated Identity Adapter), so
third-party adapters have a spec to conform to. The Desktop-only
material stays in a guide, renamed from
`docs/enterprise-identity-adapter.md` to
`docs/federated-identity-adapter.md` to match the NIP's name.
- `NIP-FA.md` holds browser login, code exchange, session check, relay
assertion (including the denial table), NIP-FI assertion transport,
security considerations, and privacy. Agent-key assertions are out of
scope.
- `docs/federated-identity-adapter.md` is a short Desktop guide for the
client in block/buzz-app: build configuration
(`BUZZ_BUILD_ENTERPRISE_AUTH_*`), what is implemented today (build
config, discovery gate) versus planned (browser login, session check,
assertion fetch, profile projection), and the vendor-specific headers
adapters must not use as session credentials.
Endpoints, fields, headers, and denial codes are unchanged. The NIP
converts lowercase requirements to BCP 14 keywords and pins rules the
old guide left open:
- `/v1/session` reports a bad session as 401 `session_required` /
`session_expired`; clients discard the session only on those, and keep
it and retry with backoff on 429, 5xx, and network failures, matching
the assertion endpoint.
- The assertion `aud` is `https://` plus the canonical `relay_url`
authority (`wss://c.example.com:8443` → `https://c.example.com:8443`),
and adapters issue dedicated `nip-fi+jwt` assertions. Relay authorities
on port 80 are not supported.
- Codes are single-use and short-lived, only `S256` challenges are
accepted, and adapters accept only `127.0.0.1` and `[::1]` callback
hosts.
- After code exchange the client must call `/v1/session` and compare the
RFC 3339 `expires_at` instants before committing the login.
- `session_token` is sent only to adapter endpoints, never to a relay,
and no credential is logged.
- NIP-98 proofs carry exactly one `method` and one `payload` tag;
retries honor `Retry-After` and surface failure after bounded attempts;
IPv6 relay hosts have a canonical form.
- The relay-proof header example is scoped to NIP-98-protected HTTP,
with WebSocket (NIP-42 AUTH) and Blossom (kind 24242) deferred to
NIP-FI.
---------
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>