mirror of
https://github.com/block/buzz.git
synced 2026-11-14 05:12:27 +00:00
## Summary Record that a community requires NIP-FI and provide an idempotent operator API to enable it and recover the original receipt. Acceptance checks the current owner, host, lifecycle and existing issuer trust in one transaction. It never creates trust. Receipts survive community deletion so an operation UUID cannot be reused. New acceptances default to disabled through `BUZZ_NIP_FI_UPGRADES_ENABLED`. Disabled calls return HTTP 503 `upgrades_disabled`; identical retries and authenticated readback still recover existing receipts. Policy is permanent: stopping new calls or rolling back code does not clear it. Last-issuer removal is refused for required communities, and bulk backfill leaves missing bindings for explicit operator repair. Apply additive migration 0082 before code. Existing communities remain optional. This layer records acceptance and reports only `pending` or `unavailable`; deploy and verify [per-request enforcement](https://github.com/block/buzz/pull/8988) and [session activation](https://github.com/block/buzz/pull/8991) before enabling acceptance. The global enforce floor remains. See [the operator contract](https://github.com/block/buzz/blob/seiler/BUZZ-510/relay-auth-upgrade/docs/community-auth-upgrades.md) for signing, recovery and containment. ### Related issue [BUZZ-510](https://linear.app/squareup/issue/BUZZ-510/relay-persist-community-nip-fi-requirements-and-expose-idempotent). Base of native stack `8989`; retains trunk's enforcement telemetry from [#8974](https://github.com/block/buzz/pull/8974). Includes a separate commit that repairs an observer timing flake in a desktop test required by the repository gate. ### Testing Controlled local regressions detect the old disabled error code and removal of the last issuer with `allow_last=true`; intended source is restored. Full PostgreSQL validation and the updated live relay/admin exercise remain pending. Human acceptance remains open. No deployment or production activation. Generated with Codex --------- Signed-off-by: Brad Seiler <seiler@squareup.com> Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
188 lines
10 KiB
Markdown
188 lines
10 KiB
Markdown
# Community NIP-FI upgrade operator contract
|
|
|
|
The acceptance layer records durable policy. The per-request enforcement layer
|
|
adds community checks and discovery; the activation layer updates existing sessions.
|
|
Do not expose upgrade calls in a deployment until those releases and the serving
|
|
fleet baseline have been verified. This change alone cannot establish enforcement.
|
|
It never reports `active` and never substitutes a timer for activation evidence.
|
|
|
|
The operator HTTP plane is separate from tenant event ingest. These management
|
|
operations extend the existing provisioning API; community clients cannot invoke
|
|
them with ordinary membership credentials. Both routes require an allowlisted
|
|
operator's NIP-98 signature bound to the configured operator API origin, exact
|
|
method and URL, including the query string on GET, and the POST body hash.
|
|
Operator POST verification does not bind query strings. Each attempt needs a fresh
|
|
signature; operation idempotency does not bypass transport replay protection.
|
|
Responses use `Cache-Control: no-store`.
|
|
|
|
`BUZZ_NIP_FI_UPGRADES_ENABLED` defaults to `false`. While disabled, new
|
|
acceptances return 503 `upgrades_disabled` with no policy mutation or
|
|
receipt. Authorized GET and identical POST receipt recovery remain available;
|
|
principal checks and payload conflicts are unchanged. Enabling this deployment
|
|
switch is an explicit operator action after the enforcement and activation fleet
|
|
baseline is verified; caller release discipline alone cannot enable acceptance.
|
|
|
|
## Generation storage
|
|
|
|
`communities.policy_generation` is `BIGINT NOT NULL DEFAULT 0`, without a
|
|
nonnegative database CHECK. Supported writers retain the current generation
|
|
when the community already requires FI, or increment it by one on acceptance.
|
|
Starting from zero, those writes preserve the nonnegative invariant. Direct SQL
|
|
can insert a negative community generation and is outside that writer contract.
|
|
Positive-generation CHECKs on acceptance receipts remain in place; activation
|
|
and receipt matching still require the exact accepted generation.
|
|
|
|
The community CHECK is omitted to avoid validating existing rows while adding
|
|
the column. The current schema planner folds a column and its CHECK together,
|
|
and can rebuild and validate a CHECK when its validation metadata differs.
|
|
Migration, fresh desired-state creation and later reconciliation therefore all
|
|
omit this community CHECK. No bootstrap or schema-tooling workaround is required.
|
|
|
|
## Enable
|
|
|
|
`POST /operator/communities/nip-fi-upgrade`
|
|
|
|
```json
|
|
{
|
|
"operation_id": "c2ffcc20-9505-48fd-b6c7-446aa833bcd1",
|
|
"community_id": "c9acf9a6-37d5-4af1-8df9-92a165637b01",
|
|
"expected_host": "team.communities.example",
|
|
"actor_pubkey": "<owner's registered 64-character hex public key>",
|
|
"expected_owner_pubkey": "<expected current owner's hex public key>",
|
|
"expected_issuer": "https://identity.example/api/goose"
|
|
}
|
|
```
|
|
|
|
The trusted operator mediates the authenticated actor. Kgoose must resolve that
|
|
actor's usable registered key from its session; it must never accept a user-supplied
|
|
actor key as identity. The actor must equal the expected and current owner.
|
|
`actor_pubkey` records the mediator's claimed authenticated actor for audit and
|
|
idempotency; it is not an independently verified end-user credential. The relay
|
|
authenticates the operator and checks the stored current owner.
|
|
Host and keys normalize before comparison; issuer bytes match exactly. UUIDs
|
|
must be non-nil. Unknown JSON fields are rejected.
|
|
|
|
The deployment selects the expected issuer from the **single** authorized issuer
|
|
in `BUZZ_NIP_FI_PROVISION_BINDING`. An absent or ambiguous selection is unavailable.
|
|
The target must be bindable under that policy and the issuer must have a configured
|
|
`BUZZ_NIP_FI_ISSUERS` policy. The same issuer must already be bound to the community
|
|
in `community_nip_fi_issuers`. The call never adds or broadens trust.
|
|
|
|
A transaction serializes UUID reuse across targets, then community lifecycle,
|
|
host and owner checks against transfer/archive/delete. It locks the exact issuer
|
|
row against unbind. Only a first optional-to-required transition increments
|
|
`policy_generation`; concurrent distinct operations converge on that generation.
|
|
Acceptance and its receipt commit together. No timeout or retry clears the policy.
|
|
|
|
## Readback and response
|
|
|
|
`GET /operator/communities/nip-fi-upgrade?operation_id=<UUID>`
|
|
|
|
Only the original authenticated operator can recover the receipt. Readback
|
|
survives owner transfer, archive and deletion; the recorded owner is historical
|
|
acceptance evidence, not authority for a fresh enable.
|
|
|
|
Enable and readback return HTTP 200 for an accepted receipt:
|
|
|
|
```json
|
|
{
|
|
"operation_id": "c2ffcc20-9505-48fd-b6c7-446aa833bcd1",
|
|
"community_id": "c9acf9a6-37d5-4af1-8df9-92a165637b01",
|
|
"expected_host": "team.communities.example",
|
|
"actor_pubkey": "<owner at acceptance>",
|
|
"expected_owner_pubkey": "<expected owner at acceptance>",
|
|
"expected_issuer": "https://identity.example/api/goose",
|
|
"operator_pubkey": "<authenticated operator>",
|
|
"policy_generation": 1,
|
|
"requires_nip_fi": true,
|
|
"state": "pending",
|
|
"pending_reasons": ["registry_baseline_unverified"]
|
|
}
|
|
```
|
|
|
|
`requires_nip_fi: true` records durable acceptance. `state` reports availability
|
|
and activation separately. The acceptance layer returns `pending` while the target's policy,
|
|
lifecycle and issuer binding still match, and `unavailable` if they do not or
|
|
trusted issuer configuration is no longer applicable. A missing database response
|
|
is HTTP 503, never a fabricated receipt. A receipt is not proof of current service
|
|
health. No response in this version reports `active`.
|
|
|
|
`pending_reasons` applies only while `state` is `pending`; clients must ignore
|
|
those obligations when state is `unavailable`, which always takes precedence.
|
|
`pending_reasons` is extensible. Clients must preserve pending on unknown values.
|
|
`registry_baseline_unverified` prevents completion until the activation layer verifies fleet
|
|
membership. That layer may add `boot_application_pending`, `boot_drain_pending`
|
|
and `boot_unreachable`, and must bind activation evidence to the exact operation
|
|
and generation. Baseline invalidation must prevent `active`.
|
|
|
|
The UUID binds the normalized community/host/actor/owner/issuer/operator tuple.
|
|
Identical retries recover that tuple and generation before rechecking mutable
|
|
preconditions. Changed payload or principal conflicts. After a lost POST response,
|
|
read by UUID or retry the same payload with a fresh NIP-98 signature. Do not generate
|
|
a new UUID to hide an ambiguous response. No rollback endpoint exists.
|
|
|
|
| HTTP | `code` | Meaning |
|
|
|---|---|---|
|
|
| 400 | `invalid_request` | Invalid enable JSON, UUID, host or key |
|
|
| 401/403 | Existing operator auth errors | Missing/invalid credentials or operator not allowlisted |
|
|
| 404 | `community_not_found` | No target for a first acceptance |
|
|
| 404 | `operation_not_found` | No receipt owned by this operator |
|
|
| 409 | `idempotency_conflict` | UUID already bound to a different tuple/principal |
|
|
| 409 | `host_conflict` | ID does not map to expected host |
|
|
| 409 | `owner_conflict` | Actor/expected owner/current owner disagree |
|
|
| 409 | `lifecycle_conflict` | Archive/deletion or durable deletion intent |
|
|
| 409 | `issuer_conflict` | Expected issuer/host not approved, or exact stored binding absent |
|
|
| 503 | `upgrades_disabled` | New acceptances disabled; wait for operator enablement, preserving the same UUID |
|
|
| 503 | `authorization_unavailable` | Trusted config or authoritative DB unavailable; retry/read back same UUID |
|
|
|
|
Malformed GET queries use Axum's existing query-extractor rejection. Transport
|
|
signature replay uses the existing operator replay error independently of this table.
|
|
|
|
## Schema and rollout
|
|
|
|
Migration 0082 is additive: legacy rows keep `requires_nip_fi=false` and generation
|
|
zero. It adds no trigger, function, procedure, rule or foreign key and rewrites no
|
|
existing rows. Lock and statement budgets are 5 and 30 seconds. Existing global
|
|
`enforce` remains an additional floor; this layer changes no data-plane gate.
|
|
Do not infer requirements from issuer rows, backfill free communities, or remove
|
|
the enterprise enforce floor as part of this migration.
|
|
|
|
Apply schema before new code. Desired-state and migration definitions match;
|
|
The registry entry is static lint metadata, so it is present in the migration
|
|
and `schema/seeds/`, not the pgschema reconciliation script (database.md Rule 3).
|
|
This follows the other operator-global registry entries. Receipts intentionally survive community purge:
|
|
the target ID/host/owner tuple prevents reusing a lost-response UUID for another
|
|
community, including after deletion. They are operator-only recovery evidence.
|
|
No tenant serving writer owns this table and no fence trigger is attached.
|
|
|
|
The active-community primary accessor is
|
|
`Db::community_auth_policy(CommunityId) -> Result<Option<CommunityAuthPolicy>>`.
|
|
`None` means no active target; errors must fail closed. Per-request enforcement must combine
|
|
its stored requirement with global enforce and honor repair denial first.
|
|
|
|
## Mistaken acceptance and containment
|
|
|
|
Stop the initiating caller and set `BUZZ_NIP_FI_UPGRADES_ENABLED=false` to prevent
|
|
new acceptances. Keep authenticated status/readback available. Record the exact
|
|
operation, target and generation for operator-led incident assessment and recovery.
|
|
Preserve the durable requirement, issuer trust and receipts while restoring a
|
|
working FI path or containing the affected community through the established
|
|
archive/lifecycle controls. Coordinate any necessary issuer/configuration repair
|
|
through its existing operator procedure; never broaden trust to make a probe pass.
|
|
|
|
Disabling calls or rolling back code does not undo acceptance. Never restore a
|
|
serving binary that ignores the durable requirement; retain effective enforcement
|
|
or contain the community while repairing the FI path. There is no
|
|
clear-policy API or routine SQL reset, and neither a timeout nor a mistaken call
|
|
is permission to return the community to free admission. Recovery must retain
|
|
the accepted requirement and operation evidence. No fleet-wide drain or deployment
|
|
is an automatic fallback.
|
|
|
|
Last-issuer removal is refused once the community requires NIP-FI, including
|
|
when the operator binding path requests `allow_last`. Bind a replacement before
|
|
removing an issuer, or use archive/lifecycle controls for containment. Removing
|
|
the issuer recorded by a receipt while another remains makes that receipt
|
|
`unavailable`; it never clears the requirement or destroys the receipt.
|
|
Bulk backfill does not recreate missing bindings for required communities;
|
|
its unbound report still exposes them for explicit operator repair.
|