From 91ca92bdb4dacc741eee3d978940e5ff0d0c86b3 Mon Sep 17 00:00:00 2001 From: cyberpunk Date: Fri, 9 Oct 2026 14:23:02 -0700 Subject: [PATCH 1/8] docs(nips): specify shared channel labels and command outcomes Signed-off-by: cyberpunk --- docs/nips/NIP-CL.md | 253 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 docs/nips/NIP-CL.md diff --git a/docs/nips/NIP-CL.md b/docs/nips/NIP-CL.md new file mode 100644 index 00000000000..29f6679bdf5 --- /dev/null +++ b/docs/nips/NIP-CL.md @@ -0,0 +1,253 @@ +NIP-CL +====== + +Channel Labels +-------------- + +`draft` `optional` `relay` + +## Abstract + +This specification defines bounded, shared string labels for Buzz stream and forum channels. Clients create initial labels with kind `9007`, add or remove labels with kind `9002`, and read the authoritative label set from relay-signed kind `39000` channel metadata. + +This NIP also defines atomic application and retry behavior for **all kind `9007` creation commands that include `h`, including unlabeled creation**. It does not change the contract for unlabeled creation without `h`. + +These tags extend [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md); they do not claim standard NIP-29 label semantics. They deliberately use `label`, not [NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) `l`/`L` tags: this is a shared channel-metadata set, without NIP-32 namespace semantics or a `#l` query contract. + +The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as normative requirements. + +## Scope + +Labels are channel metadata, not per-user organization. They MUST NOT change channel identity, history, Canvas, membership or permissions. Label values and namespace-like prefixes confer no authority. This specification reserves no values or prefixes and assigns no feature-specific behavior to them. + +Version 1 applies only to stream and forum channels. Relays MUST reject initial labels on direct-message channels and label mutations targeting direct-message channels. + +Archived channels retain readable labels but MUST reject new label mutations. A client must unarchive the channel separately before submitting a new mutation. Deleted channels MUST NOT accept label mutations or receive new metadata snapshots. A deleted channel UUID MUST NOT be reused for creation. + +Replace-all operations, relay-wide label search and consumer interfaces are outside this specification. + +## Capability discovery + +A supporting relay MUST advertise `nip-cl` in its NIP-11 `supported_extensions` string array, not in `supported_nips`: + +```json +{ + "supported_extensions": ["nip-cl"] +} +``` + +Clients MUST check this capability before submitting labeled creation or label mutation commands, and before relying on this NIP's acknowledgement guarantees for unlabeled `h`-tagged creation. Relays MUST independently enforce whether these operations are enabled. Advertisement alone is not an enforcement mechanism. + +A relay MUST NOT advertise support until all serving command and metadata writers preserve the guarantees in this specification. + +## Label values and limits + +A label value MUST be a nonempty ASCII string matching the complete pattern: + +```text +^[a-z0-9][a-z0-9._:/-]*$ +``` + +Each value MUST be at most 64 bytes. Relays MUST reject noncanonical values rather than trimming, lowercasing, normalizing or otherwise rewriting them. Clients MUST preserve valid values they do not recognize. + +The following limits apply: + +| Limit | Maximum | +|---|---:| +| Labels stored on one channel | 32 | +| Bytes in one label value | 64 | +| Raw label tags in one creation or mutation command, before deduplication | 64 | +| Bytes in the compact-JSON array of snapshot label tags | 4,096 | + +For creation, the raw count covers `label` tags. For mutation, it covers `add-label` and `remove-label` tags combined. Every label or label-operation tag MUST contain exactly two elements: its tag name and its value. + +The snapshot-array limit applies to the array containing only the `label` tags, not to the whole snapshot. Existing whole-event and transport-frame limits also apply. A relay MUST reject a command whose resulting snapshot would exceed an applicable limit before applying the command. + +## Events + +| Purpose | Kind | Channel identifier | Label tags | +|---|---:|---|---| +| Create a channel with initial labels | `9007` | `h` | `label` | +| Add or remove labels | `9002` | `h` | `add-label`, `remove-label` | +| Read authoritative channel metadata | `39000` | `d` | `label` | + +Creation and mutation commands are signed by the requesting author. Metadata snapshots are signed by the relay. Existing envelope, authentication and channel-creation requirements continue to apply. + +An `h`-tagged creation or label mutation command MUST contain exactly one `h` tag with exactly two elements: `h` and a valid, non-nil channel UUID. Duplicate or malformed channel identifiers, and missing identifiers where required, MUST be rejected before command application. Labeled creation without `h` MUST be rejected. + +The examples below are event fragments. Normal event fields, signatures and any required authentication tags are omitted. + +### Initial labels: kind 9007 + +Initial labels are expressed as repeated `label` tags alongside the ordinary creation fields: + +```json +{ + "kind": 9007, + "tags": [ + ["h", "42fdbd94-13e6-4f82-b065-5812e4a02a8b"], + ["channel_type", "stream"], + ["name", "Build Systems"], + ["label", "team:infra"], + ["label", "workflow/build"] + ], + "content": "" +} +``` + +Repeated values MUST be deduplicated in the applied set, without rewriting the signed command. Omitting initial labels creates an empty label set. Initial fields, labels, owner membership and the authoritative metadata snapshot MUST be committed together for the `h`-tagged creation path, whether or not labels are present. Rejection MUST leave no newly created channel or owner membership. + +After the retry admission checks below, an exact retry of a committed creation command MUST be recognized before treating an existing channel as a creation conflict. A different creation event targeting an existing or deleted UUID MUST be rejected as a conflict. It MUST NOT disclose the channel's metadata. + +### Add and remove labels: kind 9002 + +A label mutation MUST contain at least one `add-label` or `remove-label` tag: + +```json +{ + "kind": 9002, + "tags": [ + ["h", "42fdbd94-13e6-4f82-b065-5812e4a02a8b"], + ["add-label", "status:active"], + ["remove-label", "status:queued"] + ], + "content": "" +} +``` + +A label mutation MUST be label-only. It MUST NOT also change name, about, visibility, archive state, topic, purpose or TTL. Required envelope and authentication tags remain permitted. Malformed or unknown-tag handling MUST NOT allow a label operation to bypass privileged metadata authorization. + +Let `L` be the current label set, `A` the distinct values of all `add-label` tags, and `R` the distinct values of all `remove-label` tags. The relay MUST reject the command if `A` and `R` intersect. Otherwise, the resulting set is: + +```text +L' = (L ∪ A) ∖ R +``` + +Adding an existing value or removing an absent value succeeds as a no-op, subject to the same validation and authorization as a state-changing command. A new no-op command MUST record committed application but reuse the existing authoritative snapshot without changing its ID or timestamp. It MUST NOT publish a new snapshot solely for the no-op. A missing or stale snapshot MUST be repaired under the publication guarantees below before acknowledging success. + +The resulting set, not the intermediate order of tags, determines the stored-label limit. Operations MUST preserve labels not named by the command. Ordinary metadata commands that omit label operations MUST preserve the current label set. `label` tags on kind `9002` MUST NOT be interpreted as replacement or mutation operations. + +### Metadata snapshot: kind 39000 + +The relay publishes the complete current label set as `label` tags on the channel metadata snapshot: + +```json +{ + "kind": 39000, + "tags": [ + ["d", "42fdbd94-13e6-4f82-b065-5812e4a02a8b"], + ["t", "stream"], + ["name", "Build Systems"], + ["label", "status:active"], + ["label", "team:infra"], + ["label", "workflow/build"] + ], + "content": "" +} +``` + +Label tags MUST be deduplicated and sorted lexicographically by ASCII value. No `label` tags means an empty label set. The existing `channel_type` creation tag and `t` metadata tag retain their existing meanings; labels do not replace channel type. + +Clients MUST read labels from authorized, relay-signed channel metadata, not infer authoritative state from stored command events. Before accepting a snapshot, a client MUST verify its NIP-01 event ID and signature, and require its signer to match the relay identity obtained from the configured relay's authenticated NIP-11 `self` field or an explicitly trusted out-of-band identity. A valid signature from an arbitrary key is insufficient. The snapshot MUST identify the expected channel with exactly one two-element `d` tag and contain a complete canonical label set within the limits above; otherwise the client MUST discard it, not interpret it as an empty set. + +Clients MUST scope cached snapshots to the community and relay identity, and select replacements using NIP-01 ordering: greatest `created_at`, then lowest lexicographic event ID on a tie. An older snapshot arriving later MUST NOT roll labels back. + +Existing ACL-filtered discovery and exact channel reads include labels. This extension does not define a `#label` query filter or guarantee a global live metadata feed. Filtering a locally loaded catalog does not establish relay-wide completeness. + +Label commands and metadata snapshots are state events, not conversation messages. Receiving them MUST NOT increase chat or reply counts, add chat unreads, mark a conversation read, or generate mention or push notifications from their tags. + +## Authorization and visibility + +Creation and label-only mutation require `channels:write` and the existing identity and community admission checks. + +New label mutations require channel owner/admin authority or the existing exception for the owning human of an active owner agent. Under that exception, the human need not independently be a channel member. Ordinary membership alone is insufficient. Authorization and channel lifecycle state MUST be checked against the state with which the command is serialized, not against an earlier cached role or channel read. + +Any authorized label writer can add or remove any valid value; a prefix does not give its creator exclusive control. An agent that labels a channel therefore needs the same authority as any other label writer. + +Labels have exactly the same visibility as channel metadata. Open-channel labels are visible to community members permitted to discover that channel. Private-channel labels remain subject to the channel ACL. Labels MUST NOT weaken tenant isolation or bypass discovery restrictions. + +Label state, application evidence, serialization, caches and delivery MUST be scoped to the community resolved from the connection's host, as described in [the multi-tenant relay contract](../multi-tenant-relay.md). The same signed event submitted in another community MUST NOT recover evidence from the first community. + +## Application and publication guarantees + +A successful acknowledgement for a new command means committed application, not merely storage of the signed request. + +For `h`-tagged creation and state-changing label mutation, the relay MUST atomically commit the signed command, its application evidence, channel changes and authoritative signed `39000` snapshot. If application fails, none of those changes may commit. A new no-op command commits the signed command and its application evidence while preserving the authoritative snapshot as specified above. This guarantee does not require atomic publication of kinds `39001` and `39002` or successful delivery of every live notification. + +A transactionally stored command MAY itself serve as application evidence if its provenance and retention satisfy this NIP. No separate receipt table is required. Mere presence of a stored command without proof of atomic application MUST NOT be treated as success or permission to replay its effects. + +Concurrent mutations MUST serialize against authoritative channel state. Independent additions or removals MUST preserve unrelated values. State limits, authorization, archive state and deletion MUST be checked within the same serialization boundary. + +Every metadata publisher, including ordinary metadata updates and repair, MUST preserve the canonical labels. A delayed publisher MUST NOT restore stale labels, including after the final label has been removed. A newly published replacement MUST use freshly read state and a timestamp greater than the previous snapshot timestamp; it MUST NOT give stale captured tags a newer timestamp. + +Deletion and metadata publication MUST serialize so that a deleted channel cannot regain a metadata snapshot. Repair MUST compare label content, not merely snapshot existence, and MUST NOT republish missing or deleted channels. + +A successful command proves its application at a point in time. A later authorized command may supersede its effects. + +## Acknowledgements, retries and recovery + +An exact retry resubmits the same signed event, without changing its tags, timestamp, ID or signature. This NIP introduces no separate receipt event kind or HTTP API. + +### Retry admission + +Version 1 does not provide historical receipt recovery that bypasses freshness or current access checks. Every submission, including an exact retry, MUST pass signature, authenticated-author, transport, community, token-scope and the existing ±900-second timestamp checks before a positive acknowledgement. A positive retry acknowledgement also requires the channel to exist, be unarchived and undeleted, and the author to currently satisfy the label-mutation authority rules above. This applies to retries of creation commands as well as mutations. Channel authority and lifecycle checks MUST share the command's serialization boundary. + +After those checks, a retained committed command MUST succeed without reapplication or republication, even if a later command has superseded its effects. The relay MUST perform duplicate detection before a new creation's UUID-conflict check and repeat it within the serialization boundary before any new application. + +Failure of current admission MUST reject this submission without applying anything. It MUST NOT turn an earlier committed command into an unapplied command. Opposing edits, archive, channel deletion, channel-role revocation and soft deletion of the command event MUST NOT make the command eligible for reapplication. An expired command MUST NOT be newly applied. + +### Responses and client outcomes + +For commands covered by this NIP received as a parseable `EVENT` with an event ID, relays MUST return a NIP-01 `OK` response using the following outcomes. Prefixes in this table are fixed, case-sensitive strings; human-readable details MAY follow them after a space. Clients MUST NOT depend on those details. + +| Relay result | `OK` accepted | Message or required prefix | Meaning | +| --- | --- | --- | --- | +| New application committed, including a no-op | `true` | Empty string | This exact command committed. | +| Retained committed application, after retry admission | `true` | `duplicate: nip-cl-committed` | This exact command committed earlier; nothing was reapplied or republished. | +| Submission rejected before application | `false` | `invalid: nip-cl-rejected`, `auth-required: nip-cl-rejected`, `restricted: nip-cl-rejected`, `duplicate: nip-cl-rejected`, `rate-limited: nip-cl-rejected` or `error: nip-cl-rejected`, according to cause | This submission did not apply. It says nothing about an earlier uncertain attempt. | +| Application outcome cannot be established | `false` | `error: nip-cl-unknown` | The relay cannot prove commit or rejection. The original outcome remains unknown. | + +Malformed commands and timestamp admission failures use `invalid:`, authentication failures use `auth-required:`, permission or lifecycle denials use `restricted:`, and a different creation event targeting a used UUID uses `duplicate:`. A failure known to have rolled back MAY use `error: nip-cl-rejected`; an uncertain commit MUST use `error: nip-cl-unknown`. A stored command without proof of application MUST produce the unknown outcome if admission permits examining it; it MUST NOT be applied again or inferred successful from current labels. Errors MUST NOT reveal inaccessible channel metadata or evidence from another community. + +Examples (event IDs abbreviated): + +```json +["OK", "", true, ""] +``` + +```json +["OK", "", true, "duplicate: nip-cl-committed"] +``` + +```json +["OK", "", false, "invalid: nip-cl-rejected timestamp outside admission window"] +``` + +```json +["OK", "", false, "error: nip-cl-unknown application evidence unavailable"] +``` + +Clients MUST persist and reuse the same signed event while delivery is uncertain. They MUST distinguish these states: + +| Observation | Client outcome | +| --- | --- | +| Positive `OK` for the exact event ID from the target relay | **Committed**, even if an earlier attempt was uncertain. | +| Explicit `nip-cl-rejected` response, with no earlier uncertain or concurrent attempt for that ID | **Rejected new command**. | +| `nip-cl-unknown`, timeout, disconnect or an unrecognized response | **Unknown** unless committed application is already known. | +| Later rejection, expiry, missing evidence or failed/inaccessible read after an uncertain attempt | **Unknown**; none proves that the earlier attempt did not commit. | + +A later negative response MUST NOT downgrade known committed application. Clients MUST NOT automatically generate a new event ID to resolve an uncertain outcome. Current-label readback is an independent, ACL-filtered observation of current state, not receipt evidence. + +### Retention + +Application evidence MUST survive soft deletion of the command event and MUST be retained until the signed command can no longer pass new-application admission. Physical retention or community deletion MAY remove evidence after that point. Community deletion MUST fence subsequent writes. Without retained evidence, an expired command MUST NOT apply and an uncertain original outcome remains unknown. Indefinite receipt recovery is not promised. + +## Compatibility and security considerations + +Clients that do not use labels can continue ordinary channel operations. Such operations MUST preserve existing labels. Clients MAY ignore label presentation without affecting navigation or channel identity. + +Relays MUST enforce the canonical value, count, size, command-shape and authorization rules before committing an operation. A label prefix is not a permission, ownership claim or trusted application identity. + +Implementations MUST keep every metadata-writing path label-preserving while labels exist. Disabling new label writes is not sufficient to make an incompatible metadata writer safe. + +Labels are shared metadata and are not suitable for secrets or private per-user notes. Exact retries remain subject to current admission; they do not restore access. Current channel metadata remains protected by its current ACL. From e292f036c90a76379f59dfde1b097b7c0eac3cca Mon Sep 17 00:00:00 2001 From: cyberpunk Date: Fri, 9 Oct 2026 15:33:23 -0700 Subject: [PATCH 2/8] docs(nips): add namespaced channel label discovery Signed-off-by: cyberpunk --- docs/nips/NIP-CL.md | 85 +++++++++++++++++++++++++++++++++------------ 1 file changed, 63 insertions(+), 22 deletions(-) diff --git a/docs/nips/NIP-CL.md b/docs/nips/NIP-CL.md index 29f6679bdf5..ce3ab4a4093 100644 --- a/docs/nips/NIP-CL.md +++ b/docs/nips/NIP-CL.md @@ -8,11 +8,11 @@ Channel Labels ## Abstract -This specification defines bounded, shared string labels for Buzz stream and forum channels. Clients create initial labels with kind `9007`, add or remove labels with kind `9002`, and read the authoritative label set from relay-signed kind `39000` channel metadata. +This specification defines bounded, shared string labels for Buzz stream and forum channels. Clients create initial labels with kind `9007`, add or remove labels with kind `9002`, and read the authoritative label set from relay-signed kind `39000` channel metadata. Ordinary NIP-01 filters discover authorized channels by label, independently of channel type. This NIP also defines atomic application and retry behavior for **all kind `9007` creation commands that include `h`, including unlabeled creation**. It does not change the contract for unlabeled creation without `h`. -These tags extend [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md); they do not claim standard NIP-29 label semantics. They deliberately use `label`, not [NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) `l`/`L` tags: this is a shared channel-metadata set, without NIP-32 namespace semantics or a `#l` query contract. +The commands extend [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md). Snapshots use [NIP-32](https://github.com/nostr-protocol/nips/blob/master/32.md) self-labeling: `l` tags in the single fixed `nip-cl` namespace label the metadata event itself. This NIP defines that event as the authoritative projection of the channel's shared label set. It introduces no kind `1985` label events or third-party label authority. The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as normative requirements. @@ -20,11 +20,11 @@ The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted a Labels are channel metadata, not per-user organization. They MUST NOT change channel identity, history, Canvas, membership or permissions. Label values and namespace-like prefixes confer no authority. This specification reserves no values or prefixes and assigns no feature-specific behavior to them. -Version 1 applies only to stream and forum channels. Relays MUST reject initial labels on direct-message channels and label mutations targeting direct-message channels. +Version 1 applies only to stream and forum channels. Relays MUST reject initial labels and label mutations for all other channel types, including direct-message channels. Supporting labels on another channel type requires an explicit extension of this scope. Archived channels retain readable labels but MUST reject new label mutations. A client must unarchive the channel separately before submitting a new mutation. Deleted channels MUST NOT accept label mutations or receive new metadata snapshots. A deleted channel UUID MUST NOT be reused for creation. -Replace-all operations, relay-wide label search and consumer interfaces are outside this specification. +Replace-all operations, consumer interfaces, creator identity tags, immutable channel creation times, duplicate-channel selection and system-channel behavior are outside this specification. Label discovery within the reader's authorized community catalog is in scope; it does not guarantee uniqueness or identify who created a channel. ## Capability discovery @@ -36,9 +36,9 @@ A supporting relay MUST advertise `nip-cl` in its NIP-11 `supported_extensions` } ``` -Clients MUST check this capability before submitting labeled creation or label mutation commands, and before relying on this NIP's acknowledgement guarantees for unlabeled `h`-tagged creation. Relays MUST independently enforce whether these operations are enabled. Advertisement alone is not an enforcement mechanism. +Clients MUST check this capability before submitting labeled creation or label mutation commands, relying on label discovery, or relying on this NIP's acknowledgement guarantees for unlabeled `h`-tagged creation. Relays MUST independently enforce whether these operations are enabled. Advertisement alone is not an enforcement mechanism. -A relay MUST NOT advertise support until all serving command and metadata writers preserve the guarantees in this specification. +A relay MUST NOT advertise support until all serving command and metadata writers and discovery readers preserve the guarantees in this specification, including canonical snapshots for existing labeled channels. ## Label values and limits @@ -57,11 +57,10 @@ The following limits apply: | Labels stored on one channel | 32 | | Bytes in one label value | 64 | | Raw label tags in one creation or mutation command, before deduplication | 64 | -| Bytes in the compact-JSON array of snapshot label tags | 4,096 | -For creation, the raw count covers `label` tags. For mutation, it covers `add-label` and `remove-label` tags combined. Every label or label-operation tag MUST contain exactly two elements: its tag name and its value. +For creation, the raw count covers `label` tags. For mutation, it covers `add-label` and `remove-label` tags combined. Each of these command tags MUST contain exactly two elements: its tag name and its value. Snapshot `l` and `L` tags have the shapes specified below. -The snapshot-array limit applies to the array containing only the `label` tags, not to the whole snapshot. Existing whole-event and transport-frame limits also apply. A relay MUST reject a command whose resulting snapshot would exceed an applicable limit before applying the command. +Existing whole-event and transport-frame limits also apply. A relay MUST reject a command whose resulting snapshot would exceed an applicable limit before applying the command. ## Events @@ -69,9 +68,9 @@ The snapshot-array limit applies to the array containing only the `label` tags, |---|---:|---|---| | Create a channel with initial labels | `9007` | `h` | `label` | | Add or remove labels | `9002` | `h` | `add-label`, `remove-label` | -| Read authoritative channel metadata | `39000` | `d` | `label` | +| Read authoritative channel metadata | `39000` | `d` | `L`, `l` | -Creation and mutation commands are signed by the requesting author. Metadata snapshots are signed by the relay. Existing envelope, authentication and channel-creation requirements continue to apply. +Creation and mutation commands are signed by the requesting author. Metadata snapshots are signed by the relay. Existing envelope, authentication and channel-creation requirements continue to apply. Relays MUST reject `add-label` or `remove-label` on kind `9007`, `label` on kind `9002`, and `l`, `L` or `t` on either command kind. These are misplaced tags, not alternative label formats; rejection MUST leave all command effects unapplied. An `h`-tagged creation or label mutation command MUST contain exactly one `h` tag with exactly two elements: `h` and a valid, non-nil channel UUID. Duplicate or malformed channel identifiers, and missing identifiers where required, MUST be rejected before command application. Labeled creation without `h` MUST be rejected. @@ -125,11 +124,11 @@ L' = (L ∪ A) ∖ R Adding an existing value or removing an absent value succeeds as a no-op, subject to the same validation and authorization as a state-changing command. A new no-op command MUST record committed application but reuse the existing authoritative snapshot without changing its ID or timestamp. It MUST NOT publish a new snapshot solely for the no-op. A missing or stale snapshot MUST be repaired under the publication guarantees below before acknowledging success. -The resulting set, not the intermediate order of tags, determines the stored-label limit. Operations MUST preserve labels not named by the command. Ordinary metadata commands that omit label operations MUST preserve the current label set. `label` tags on kind `9002` MUST NOT be interpreted as replacement or mutation operations. +The resulting set, not the intermediate order of tags, determines the stored-label limit. Operations MUST preserve labels not named by the command. Ordinary metadata commands that omit label operations MUST preserve the current label set. ### Metadata snapshot: kind 39000 -The relay publishes the complete current label set as `label` tags on the channel metadata snapshot: +The relay publishes the complete current label set as NIP-32 `l` tags, each marked with the `nip-cl` namespace: ```json { @@ -138,24 +137,62 @@ The relay publishes the complete current label set as `label` tags on the channe ["d", "42fdbd94-13e6-4f82-b065-5812e4a02a8b"], ["t", "stream"], ["name", "Build Systems"], - ["label", "status:active"], - ["label", "team:infra"], - ["label", "workflow/build"] + ["L", "nip-cl"], + ["l", "status:active", "nip-cl"], + ["l", "team:infra", "nip-cl"], + ["l", "workflow/build", "nip-cl"] ], "content": "" } ``` -Label tags MUST be deduplicated and sorted lexicographically by ASCII value. No `label` tags means an empty label set. The existing `channel_type` creation tag and `t` metadata tag retain their existing meanings; labels do not replace channel type. +A nonempty label set MUST have exactly one two-element `["L", "nip-cl"]` tag and one three-element `["l", , "nip-cl"]` tag per value. The `l` tags MUST be deduplicated and sorted lexicographically by ASCII value. An empty set MUST omit both `L` and `l` tags. Snapshots MUST NOT contain other `L` namespaces, unmarked or differently marked `l` tags, or legacy `label` tags. Clients MUST discard a noncanonical label representation rather than partially accepting it or treating it as empty. + +This single-namespace rule is deliberate: NIP-01 indexes only the first value of a tag. `#L` and `#l` match independently; they cannot require the namespace mark of a particular `l` tag. Allowing other namespaces on the same snapshot could therefore produce false label matches. + +The existing `channel_type` creation tag retains its meaning. A snapshot MUST contain exactly one two-element `t` tag identifying its channel type; `t` MUST NOT carry labels. A value such as `stream` is a valid label in `l` and does not change the channel type. Clients MUST read labels from authorized, relay-signed channel metadata, not infer authoritative state from stored command events. Before accepting a snapshot, a client MUST verify its NIP-01 event ID and signature, and require its signer to match the relay identity obtained from the configured relay's authenticated NIP-11 `self` field or an explicitly trusted out-of-band identity. A valid signature from an arbitrary key is insufficient. The snapshot MUST identify the expected channel with exactly one two-element `d` tag and contain a complete canonical label set within the limits above; otherwise the client MUST discard it, not interpret it as an empty set. Clients MUST scope cached snapshots to the community and relay identity, and select replacements using NIP-01 ordering: greatest `created_at`, then lowest lexicographic event ID on a tie. An older snapshot arriving later MUST NOT roll labels back. -Existing ACL-filtered discovery and exact channel reads include labels. This extension does not define a `#label` query filter or guarantee a global live metadata feed. Filtering a locally loaded catalog does not establish relay-wide completeness. - Label commands and metadata snapshots are state events, not conversation messages. Receiving them MUST NOT increase chat or reply counts, add chat unreads, mark a conversation read, or generate mention or push notifications from their tags. +## Finding channels + +Supporting relays MUST match ordinary [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) `#L` and `#l` filters on the current relay-signed kind `39000` snapshots. These filters compose with the existing `#t` channel-type and `#d` channel-ID filters. Exact channel reads and existing ACL-filtered discovery MUST include the same canonical labels. No custom `#label` filter or new discovery endpoint is introduced. + +For example, this filter finds authorized stream channels labeled `team:infra`: + +```json +{ + "kinds": [39000], + "#t": ["stream"], + "#L": ["nip-cl"], + "#l": ["team:infra"], + "limit": 50 +} +``` + +To find either of two labels, irrespective of channel type within this NIP's scope: + +```json +{ + "kinds": [39000], + "#L": ["nip-cl"], + "#l": ["team:infra", "workflow/build"], + "limit": 50 +} +``` + +Values within one filter field combine with OR; different fields combine with AND; multiple filter objects combine with OR. `#l: ["a", "b"]` therefore does not require both labels. Clients needing an intersection can query candidates and inspect their complete label sets. `authors`, when supplied, identifies the relay signer, not the channel creator. + +Relays MUST select current snapshots, enforce current metadata-read authorization and match all filter predicates **before applying `limit`**, using NIP-01 result ordering. Nonmatching, superseded or inaccessible snapshots MUST NOT consume the result limit. Fetching only the newest unfiltered page and then filtering it is not conformant: an older matching channel must not disappear behind newer nonmatching channels. The same rule applies through the relay's generic HTTP query bridge; this NIP does not add another read-authorization path. + +Live delivery remains subject to existing subscription scope, current ACLs and filter matching; this NIP does not promise a global live metadata feed or a complete catalog from one limited query. When a channel loses a queried label, its replacement snapshot no longer matches that filter, so the subscription is not a removal feed. Clients maintaining a catalog MUST reconcile retained entries through authorized exact-channel reads or broader subscriptions. Filtering a partially loaded catalog does not establish relay-wide completeness. + +Labels remain mutable shared metadata, not unique IDs or creator provenance. An empty result MUST NOT be treated as proof that no same-labeled channel exists outside the returned query scope, limits or access rights. Two distinct creation commands may create different channels with the same labels; this NIP's exact-event retry guarantee does not deduplicate those channels. + ## Authorization and visibility Creation and label-only mutation require `channels:write` and the existing identity and community admission checks. @@ -198,7 +235,11 @@ Failure of current admission MUST reject this submission without applying anythi ### Responses and client outcomes -For commands covered by this NIP received as a parseable `EVENT` with an event ID, relays MUST return a NIP-01 `OK` response using the following outcomes. Prefixes in this table are fixed, case-sensitive strings; human-readable details MAY follow them after a space. Clients MUST NOT depend on those details. +Once a parseable `EVENT` covered by this NIP reaches command-specific handling, the relay MUST respond with a NIP-01 `OK` using the outcomes below. This includes validation and retry-admission failures in that handling. A positive `OK` for a covered command MUST always mean committed application; generic event storage or deduplication MUST NOT acknowledge it as successful without application evidence. + +Earlier generic admission or transport failures, such as frame rejection, authentication admission or handler saturation, MAY instead use ordinary NIP-01 rejection responses or close the connection. They are not NIP-CL receipts. Clients MUST treat a negative response without a recognized `nip-cl-rejected` or `nip-cl-unknown` prefix, a disconnect or a missing response as **Unknown** unless committed application is already known, even if this is the first attempt. The relay MUST NOT use `nip-cl-rejected` unless it can prove this submission did not apply. + +Prefixes in this table are fixed, case-sensitive strings; human-readable details MAY follow them after a space. Clients MUST NOT depend on those details. | Relay result | `OK` accepted | Message or required prefix | Meaning | | --- | --- | --- | --- | @@ -233,7 +274,7 @@ Clients MUST persist and reuse the same signed event while delivery is uncertain | --- | --- | | Positive `OK` for the exact event ID from the target relay | **Committed**, even if an earlier attempt was uncertain. | | Explicit `nip-cl-rejected` response, with no earlier uncertain or concurrent attempt for that ID | **Rejected new command**. | -| `nip-cl-unknown`, timeout, disconnect or an unrecognized response | **Unknown** unless committed application is already known. | +| `nip-cl-unknown`, generic rejection without a recognized NIP-CL prefix, timeout, disconnect or an unrecognized response | **Unknown** unless committed application is already known. | | Later rejection, expiry, missing evidence or failed/inaccessible read after an uncertain attempt | **Unknown**; none proves that the earlier attempt did not commit. | A later negative response MUST NOT downgrade known committed application. Clients MUST NOT automatically generate a new event ID to resolve an uncertain outcome. Current-label readback is an independent, ACL-filtered observation of current state, not receipt evidence. @@ -248,6 +289,6 @@ Clients that do not use labels can continue ordinary channel operations. Such op Relays MUST enforce the canonical value, count, size, command-shape and authorization rules before committing an operation. A label prefix is not a permission, ownership claim or trusted application identity. -Implementations MUST keep every metadata-writing path label-preserving while labels exist. Disabling new label writes is not sufficient to make an incompatible metadata writer safe. +Implementations MUST keep every metadata-writing path label-preserving while labels exist. Disabling new label writes is not sufficient to make an incompatible metadata writer safe. Competing draft snapshot formats (`label` or additional `t` tags) are not aliases: implementations using them MUST migrate state and republish canonical snapshots before advertising `nip-cl`, without guessing whether an arbitrary `t` value was a label. This specification does not define those migration mechanics. Labels are shared metadata and are not suitable for secrets or private per-user notes. Exact retries remain subject to current admission; they do not restore access. Current channel metadata remains protected by its current ACL. From d2359db0e5f822bb31d940b5e063043a0588142b Mon Sep 17 00:00:00 2001 From: cyberpunk Date: Fri, 9 Oct 2026 18:14:12 -0700 Subject: [PATCH 3/8] feat: apply channel labels through admitted metadata transactions Signed-off-by: cyberpunk --- .env.example | 6 + Cargo.lock | 1 + PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md | 27 +- crates/buzz-admin/src/channel_metadata.rs | 43 ++ crates/buzz-admin/src/main.rs | 191 +++---- crates/buzz-cli/Cargo.toml | 3 + crates/buzz-cli/src/client.rs | 21 + .../src/commands/channel_labels/journal.rs | 137 +++++ .../commands/channel_labels/journal_tests.rs | 110 +++++ .../src/commands/channel_labels/mod.rs | 299 +++++++++++ .../commands/channel_labels/snapshot_tests.rs | 127 +++++ .../src/commands/channel_labels/tests.rs | 467 ++++++++++++++++++ crates/buzz-cli/src/commands/channels.rs | 49 +- crates/buzz-cli/src/commands/mod.rs | 1 + crates/buzz-cli/src/lib.rs | 58 ++- .../buzz-core/src/channel_labels/command.rs | 124 +++++ crates/buzz-core/src/channel_labels/mod.rs | 103 ++++ .../buzz-core/src/channel_labels/outcome.rs | 174 +++++++ .../buzz-core/src/channel_labels/snapshot.rs | 79 +++ crates/buzz-core/src/channel_labels/tests.rs | 300 +++++++++++ crates/buzz-core/src/lib.rs | 2 + crates/buzz-db/src/lib.rs | 8 +- crates/buzz-db/src/runtime/migration.rs | 13 +- .../src/store/channel_metadata/activation.rs | 74 +++ .../src/store/channel_metadata/command.rs | 227 +++++++++ .../buzz-db/src/store/channel_metadata/mod.rs | 166 +++++++ .../store/channel_metadata/postgres_tests.rs | 324 ++++++++++++ .../activation_postgres_tests.rs | 149 ++++++ .../lifecycle_postgres_tests.rs | 274 ++++++++++ .../src/store/channel_metadata/snapshot.rs | 178 +++++++ crates/buzz-db/src/store/event.rs | 32 ++ .../src/store/event/metadata_filter.rs | 41 ++ .../event/metadata_filter/postgres_tests.rs | 149 ++++++ crates/buzz-db/src/store/event/tag_filter.rs | 35 ++ .../store/event/tag_filter/postgres_tests.rs | 129 +++++ crates/buzz-db/src/store/mod.rs | 2 + crates/buzz-db/tests/observability_source.rs | 14 +- .../src/api/artifact_postgres_tests.rs | 1 + crates/buzz-relay/src/api/bridge.rs | 28 +- .../src/api/channel_labels_postgres_tests.rs | 431 ++++++++++++++++ .../activation_postgres_tests.rs | 52 ++ .../repair_postgres_tests.rs | 53 ++ .../ws_postgres_tests.rs | 181 +++++++ crates/buzz-relay/src/config.rs | 7 + .../buzz-relay/src/config/channel_labels.rs | 46 ++ .../buzz-relay/src/handlers/channel_labels.rs | 242 +++++++++ .../src/handlers/channel_metadata.rs | 70 +++ crates/buzz-relay/src/handlers/count.rs | 10 +- crates/buzz-relay/src/handlers/ingest.rs | 27 + crates/buzz-relay/src/handlers/mod.rs | 4 + crates/buzz-relay/src/handlers/req.rs | 129 ++++- .../src/handlers/req/metadata_filter_tests.rs | 97 ++++ .../buzz-relay/src/handlers/side_effects.rs | 179 ++----- crates/buzz-relay/src/main.rs | 9 + crates/buzz-relay/src/nip11.rs | 5 + docs/channel-labels-rollout.md | 114 +++++ migrations/0061_channel_labels.sql | 13 + schema/tables/public/channels.sql | 2 + schema/tables/public/events.sql | 2 + scripts/channel-labels-e2e/README.md | 61 +++ scripts/channel-labels-e2e/flows.py | 86 ++++ scripts/channel-labels-e2e/proxy.py | 81 +++ scripts/channel-labels-e2e/run.py | 144 ++++++ 63 files changed, 5915 insertions(+), 296 deletions(-) create mode 100644 crates/buzz-admin/src/channel_metadata.rs create mode 100644 crates/buzz-cli/src/commands/channel_labels/journal.rs create mode 100644 crates/buzz-cli/src/commands/channel_labels/journal_tests.rs create mode 100644 crates/buzz-cli/src/commands/channel_labels/mod.rs create mode 100644 crates/buzz-cli/src/commands/channel_labels/snapshot_tests.rs create mode 100644 crates/buzz-cli/src/commands/channel_labels/tests.rs create mode 100644 crates/buzz-core/src/channel_labels/command.rs create mode 100644 crates/buzz-core/src/channel_labels/mod.rs create mode 100644 crates/buzz-core/src/channel_labels/outcome.rs create mode 100644 crates/buzz-core/src/channel_labels/snapshot.rs create mode 100644 crates/buzz-core/src/channel_labels/tests.rs create mode 100644 crates/buzz-db/src/store/channel_metadata/activation.rs create mode 100644 crates/buzz-db/src/store/channel_metadata/command.rs create mode 100644 crates/buzz-db/src/store/channel_metadata/mod.rs create mode 100644 crates/buzz-db/src/store/channel_metadata/postgres_tests.rs create mode 100644 crates/buzz-db/src/store/channel_metadata/postgres_tests/activation_postgres_tests.rs create mode 100644 crates/buzz-db/src/store/channel_metadata/postgres_tests/lifecycle_postgres_tests.rs create mode 100644 crates/buzz-db/src/store/channel_metadata/snapshot.rs create mode 100644 crates/buzz-db/src/store/event/metadata_filter.rs create mode 100644 crates/buzz-db/src/store/event/metadata_filter/postgres_tests.rs create mode 100644 crates/buzz-db/src/store/event/tag_filter.rs create mode 100644 crates/buzz-db/src/store/event/tag_filter/postgres_tests.rs create mode 100644 crates/buzz-relay/src/api/channel_labels_postgres_tests.rs create mode 100644 crates/buzz-relay/src/api/channel_labels_postgres_tests/activation_postgres_tests.rs create mode 100644 crates/buzz-relay/src/api/channel_labels_postgres_tests/repair_postgres_tests.rs create mode 100644 crates/buzz-relay/src/api/channel_labels_postgres_tests/ws_postgres_tests.rs create mode 100644 crates/buzz-relay/src/config/channel_labels.rs create mode 100644 crates/buzz-relay/src/handlers/channel_labels.rs create mode 100644 crates/buzz-relay/src/handlers/channel_metadata.rs create mode 100644 crates/buzz-relay/src/handlers/req/metadata_filter_tests.rs create mode 100644 docs/channel-labels-rollout.md create mode 100644 migrations/0061_channel_labels.sql create mode 100644 scripts/channel-labels-e2e/README.md create mode 100644 scripts/channel-labels-e2e/flows.py create mode 100644 scripts/channel-labels-e2e/proxy.py create mode 100644 scripts/channel-labels-e2e/run.py diff --git a/.env.example b/.env.example index 8e1fe159391..90e3f00f5ef 100644 --- a/.env.example +++ b/.env.example @@ -88,6 +88,12 @@ RELAY_URL=ws://localhost:3000 # Stable relay signing key (required). `just bootstrap` generates a random key in # the gitignored .env file. Preserve that value across restarts and backups. # BUZZ_RELAY_PRIVATE_KEY=<32-byte hex private key> +# NIP-CL channel labels: default off. Never opt in during a mixed-version rollout. +# Stop/fence old command AND metadata/repair writers, reconcile every community, +# then follow docs/channel-labels-rollout.md before setting these values. +# Disabling commands does not make an older metadata writer safe. +# BUZZ_NIP_CL_ENABLED=false +# BUZZ_NIP_CL_WRITER_CUTOVER=offline-v1 # Optional: path to the web UI dist directory. When set, the relay serves # the web frontend at / for browser requests. Leave unset for local dev # (use `just web` for Vite HMR instead). diff --git a/Cargo.lock b/Cargo.lock index b4986b67b40..3e335793172 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1119,6 +1119,7 @@ dependencies = [ "clap", "diffy", "dirs", + "fs2", "hex", "infer", "nostr 0.44.7", diff --git a/PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md b/PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md index c64d7e048b2..6ccbe66b857 100644 --- a/PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md +++ b/PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md @@ -27,7 +27,13 @@ event during post-write verification and report a false conflict). Every such read stays on `query_events` / writer. Display reads — a page the user scrolls, a count on a badge, a history list — -tolerate bounded staleness and take the routed path. +tolerate bounded staleness and take the routed path, except when they also +select current authorization-sensitive state. Queries that can match channel +metadata (kind `39000`, including mixed-kind and ID-only filters) carry +`EventQuery.channel_metadata_read`: current relay head and channel ACL predicates +are evaluated on the writer before filtering and limits. The routed query, +bounded-query and count APIs enforce this pin regardless of client consistency +intent. Adding, removing, or reclassifying a caller **requires updating the table below**; the `query_events_routed` doc-comment points here. @@ -53,24 +59,25 @@ in `crates/buzz-relay/src/api/bridge.rs` (`extract_consistency`). ## Caller classification table Rows below are every `query_events_routed` / `query_events_routed_bounded` -call site at head, plus the writer-pinned canvas row this change adds. (The -`count` and feed routed families — `count_events_routed`, +call site at head, plus writer-pinned metadata and canvas reads. (The +other count and feed routed families — `get_events_by_ids_routed`, `query_feed_*_routed`, and the `get_channel_window` -cursor/head reads — are all display or bounded-count surfaces on the routed +cursor/head reads — are display or bounded-count surfaces on the routed path; they carry their own soundness notes at their definitions in `crates/buzz-db/src/lib.rs` and are out of scope for this table.) | Caller / path label | Pool | Justification | |---|---|---| -| `bridge_query` (default `/query` filter) | routed | Display reads over the HTTP bridge; bounded staleness acceptable. | +| `bridge_query` (default `/query` filter) | routed, except metadata | Display reads over the HTTP bridge; metadata exception below. | | `bridge_query` + `consistency: strong` | **writer** | Client-declared write-influencing read (canvas save precondition, restore precondition, post-write ancestry verification). | -| `req_historical` (WS REQ historical page) | routed | Display backfill of a subscription; per-row re-filter absorbs a briefly-stale row. | +| `req_historical` (WS REQ historical page) | routed, except metadata | Display backfill; metadata exception below. | | `bridge_thread_aux` (`AuxReader::Routed`, thread aux page) | routed | Thread reply hydration; display, post-verified against the fence wall. | -| `bridge_count_fallback` (`query_events_routed_bounded`) | routed (bounded arm) | COUNT fallback that materializes rows; bounded arm only, never covered. | -| `count_req_fallback` (`query_events_routed_bounded`) | routed (bounded arm) | WS COUNT fallback that materializes rows; bounded arm only. | +| `bridge_count_fallback` (`query_events_routed_bounded`) | routed (bounded arm), except metadata | COUNT fallback that materializes rows; bounded arm only, never covered. | +| `count_req_fallback` (`query_events_routed_bounded`) | routed (bounded arm), except metadata | WS COUNT fallback that materializes rows; bounded arm only. | +| Any query / bounded query / count carrying `channel_metadata_read` | **writer** | Current kind-39000 head and current ACL are authority decisions, including mixed-kind and ID-only filters; no replica-staleness budget can relax them. | -The **writer** row is the only write-influencing entry; every other caller is a -display or count surface that tolerates bounded staleness. Client canvas reads +The metadata **writer** row is enforced by `buzz-db` in each routed entry point; +HTTP and WS callers attach the context in `handlers/req.rs`. Client canvas reads that gate a write set `consistency: strong` (Desktop `desktop/src-tauri/src/commands/canvas.rs`, CLI `crates/buzz-cli/src/commands/channels.rs`) so they land on the writer row. diff --git a/crates/buzz-admin/src/channel_metadata.rs b/crates/buzz-admin/src/channel_metadata.rs new file mode 100644 index 00000000000..2f3d62421f1 --- /dev/null +++ b/crates/buzz-admin/src/channel_metadata.rs @@ -0,0 +1,43 @@ +//! Operator signing for application-owned canonical metadata repair. + +use anyhow::{Context, Result}; +use buzz_core::{kind::KIND_NIP29_GROUP_METADATA, TenantContext}; +use buzz_db::Db; +use nostr::{EventBuilder, Keys, Kind}; +use uuid::Uuid; + +pub(crate) async fn repair( + db: &Db, + tenant: &TenantContext, + channel: Uuid, + keys: &Keys, +) -> Result { + let mut write = db + .begin_channel_metadata_write(tenant.community(), channel, keys.public_key()) + .await?; + if !write.needs_snapshot().await? { + write.rollback().await?; + return Ok(false); + } + let tags = write.snapshot_tags().await?; + let timestamp = write.snapshot_timestamp()?; + let keys = keys.clone(); + let event = tokio::task::spawn_blocking(move || { + EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_METADATA as u16), "") + .tags(tags) + .allow_self_tagging() + .custom_created_at(timestamp) + .sign_with_keys(&keys) + }) + .await??; + let max_bytes = match std::env::var("BUZZ_MAX_FRAME_BYTES") { + Ok(value) => value + .parse::() + .context("invalid BUZZ_MAX_FRAME_BYTES")?, + Err(std::env::VarError::NotPresent) => 512 * 1024, + Err(error) => return Err(error.into()), + }; + write.store_snapshot(&event, max_bytes).await?; + write.commit().await?; + Ok(true) +} diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 66780e20419..d399ecff752 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -20,6 +20,7 @@ //! newest timestamp and collide on the bumped second. run.sh serialization is //! the guard against parallel adds (e.g. `xargs -P`). +mod channel_metadata; mod communities; mod deletions; @@ -112,10 +113,10 @@ enum Command { #[command(subcommand)] command: deletions::DeletionsCommand, }, - /// Emit missing kind:39000/39001/39002 channel discovery events, or + /// Repair missing or stale kind:39000 channel metadata and discovery, or /// republish only a targeted channel's kind:39002 roster. /// - /// Without `--channel`, only channels missing discovery metadata are + /// Without `--channel`, channels with missing or stale metadata are /// reconciled. With `--channel`, only that channel's member snapshot is /// replaced; canonical metadata and admin events remain untouched. ReconcileChannels { @@ -124,8 +125,7 @@ enum Command { channel: Option, /// Relay private key (hex) for signing events. Falls back to - /// BUZZ_RELAY_PRIVATE_KEY env var. If neither is set, generates - /// an ephemeral key (events will be unverifiable after restart). + /// BUZZ_RELAY_PRIVATE_KEY env var. A configured relay identity is required. #[arg(long)] relay_key: Option, }, @@ -740,35 +740,18 @@ async fn reconcile_channels( relay_key_arg: Option, ) -> Result<()> { use buzz_core::kind::KIND_NIP29_GROUP_ADMINS; - use buzz_db::event::EventQuery; let db = connect_db().await?; - // Resolve relay signing key: arg > env > ephemeral. Force-republish must - // never use an ephemeral key because it replaces an existing authoritative - // snapshot. - let configured_relay_key = - relay_key_arg.or_else(|| std::env::var("BUZZ_RELAY_PRIVATE_KEY").ok()); - if channel_arg.is_some() && configured_relay_key.is_none() { - return Err(anyhow::anyhow!( - "--channel requires --relay-key or BUZZ_RELAY_PRIVATE_KEY" - )); - } - let relay_keys = match configured_relay_key { - Some(key_hex) => { - Keys::parse(&key_hex).map_err(|e| anyhow::anyhow!("invalid relay key: {e}"))? - } - None => { - let k = Keys::generate(); - eprintln!( - "Warning: no relay key provided — using ephemeral key {}", - k.public_key().to_hex() - ); - eprintln!("Events signed with this key won't be verifiable after this run."); - eprintln!("Pass --relay-key or set BUZZ_RELAY_PRIVATE_KEY for production use."); - k - } - }; + // Repair is authoritative publication. An ephemeral signer would create + // untrusted metadata, not repair the configured relay's head. + let configured_relay_key = relay_key_arg + .or_else(|| std::env::var("BUZZ_RELAY_PRIVATE_KEY").ok()) + .ok_or_else(|| { + anyhow::anyhow!("reconciliation requires --relay-key or BUZZ_RELAY_PRIVATE_KEY") + })?; + let relay_keys = Keys::parse(&configured_relay_key) + .map_err(|e| anyhow::anyhow!("invalid relay key: {e}"))?; let tenant = resolve_admin_tenant(&db).await?; let target_channel = channel_arg @@ -776,119 +759,81 @@ async fn reconcile_channels( .map(uuid::Uuid::parse_str) .transpose() .map_err(|e| anyhow::anyhow!("invalid --channel UUID: {e}"))?; - let channels = if let Some(target) = target_channel { - vec![db - .get_channel(tenant.community(), target) - .await - .map_err(|_| { - anyhow::anyhow!("channel {target} not found in community {}", tenant.host()) - })?] - } else { - db.list_channels(tenant.community(), None).await? - }; - if channels.is_empty() { - println!("No channels in database."); - return Ok(()); - } - - let mut reconciled = 0u32; - let mut skipped = 0u32; - - for channel in &channels { - let channel_id_str = channel.id.to_string(); - - // Check if kind:39000 already exists - let existing = db - .query_events(&EventQuery { - kinds: Some(vec![39000]), - d_tag: Some(channel_id_str.clone()), - limit: Some(1), - ..EventQuery::for_community(tenant.community()) - }) - .await - .unwrap_or_default(); - - if !existing.is_empty() && target_channel.is_none() { - skipped += 1; - continue; + let mut cursor = uuid::Uuid::nil(); + let mut reconciled = 0u64; + let mut skipped = 0u64; + loop { + let channels = if let Some(target) = target_channel { + // Targeted reconciliation remains roster-only, with the existing + // live-channel admission check. + db.get_channel(tenant.community(), target).await?; + vec![target] + } else { + db.channel_metadata_repair_page(tenant.community(), cursor) + .await? + }; + if channels.is_empty() { + break; } + for channel in channels { + cursor = channel; + let channel_id_str = channel.to_string(); - let members = db.get_members(tenant.community(), channel.id).await?; - - // A targeted repair is deliberately roster-only. kind:39000 metadata - // is richer than this legacy backfill builder, and kind:39001 is not - // part of the stale-roster incident; replacing either can destroy - // canonical state. Full backfill still creates all three event kinds - // for channels with no discovery metadata. - if target_channel.is_none() { - // kind:39000 — channel metadata + if target_channel.is_none() + && !channel_metadata::repair(&db, &tenant, channel, &relay_keys).await? { - let mut tags: Vec = vec![Tag::parse(["d", &channel_id_str])?]; - tags.push(Tag::parse(["name", &channel.name])?); - if let Some(ref desc) = channel.description { - if !desc.is_empty() { - tags.push(Tag::parse(["about", desc])?); + skipped += 1; + continue; + } + let members = db.get_members(tenant.community(), channel).await?; + + // Targeted repair remains roster-only. Full repair compares the complete + // projection through the same database boundary as the relay publisher. + if target_channel.is_none() { + // kind:39001 — admins + { + let mut tags: Vec = vec![Tag::parse(["d", &channel_id_str])?]; + for m in members + .iter() + .filter(|m| m.role == "owner" || m.role == "admin") + { + let pk = hex::encode(&m.pubkey); + tags.push(Tag::parse(["p", &pk, &m.role])?); } + let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_ADMINS as u16), "") + .tags(tags) + .sign_with_keys(&relay_keys) + .map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?; + db.replace_addressable_event(tenant.community(), &event, Some(channel)) + .await?; } - if channel.visibility == "private" { - tags.push(Tag::parse(["private"])?); - } else { - tags.push(Tag::parse(["public"])?); - } - if channel.channel_type == "dm" { - tags.push(Tag::parse(["hidden"])?); - } - tags.push(Tag::parse(["closed"])?); - tags.push(Tag::parse(["t", &channel.channel_type])?); - - let event = EventBuilder::new(Kind::Custom(39000), "") - .tags(tags) - .sign_with_keys(&relay_keys) - .map_err(|e| anyhow::anyhow!("sign kind:39000: {e}"))?; - db.replace_addressable_event(tenant.community(), &event, Some(channel.id)) - .await?; } - // kind:39001 — admins + // kind:39002 — members { let mut tags: Vec = vec![Tag::parse(["d", &channel_id_str])?]; - for m in members - .iter() - .filter(|m| m.role == "owner" || m.role == "admin") - { + for m in &members { let pk = hex::encode(&m.pubkey); - tags.push(Tag::parse(["p", &pk, &m.role])?); + tags.push(Tag::parse(["p", &pk, "", &m.role])?); } - let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_ADMINS as u16), "") + let event = EventBuilder::new(Kind::Custom(39002), "") .tags(tags) .sign_with_keys(&relay_keys) - .map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?; - db.replace_addressable_event(tenant.community(), &event, Some(channel.id)) + .map_err(|e| anyhow::anyhow!("sign kind:39002: {e}"))?; + db.replace_addressable_event(tenant.community(), &event, Some(channel)) .await?; } - } - // kind:39002 — members - { - let mut tags: Vec = vec![Tag::parse(["d", &channel_id_str])?]; - for m in &members { - let pk = hex::encode(&m.pubkey); - tags.push(Tag::parse(["p", &pk, "", &m.role])?); - } - let event = EventBuilder::new(Kind::Custom(39002), "") - .tags(tags) - .sign_with_keys(&relay_keys) - .map_err(|e| anyhow::anyhow!("sign kind:39002: {e}"))?; - db.replace_addressable_event(tenant.community(), &event, Some(channel.id)) - .await?; + reconciled += 1; + } + if target_channel.is_some() { + break; } - - reconciled += 1; } println!( - "Reconciled {reconciled} channels ({skipped} already had events, {} total).", - channels.len() + "Reconciled {reconciled} channels ({skipped} already current, {} total).", + reconciled + skipped ); Ok(()) } diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 59d1bb2cee6..53c6cdbb0e6 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -83,6 +83,9 @@ buzz-ws-client = { path = "../buzz-ws-client" } # auto-select a provider. See crates/buzz-acp/Cargo.toml for the same dependency. rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +# Cross-process journal exclusion, compatible with the workspace Rust 1.88 MSRV. +fs2 = "0.4" + # Random number generation — full jitter for exponential backoff in with_retry rand = { workspace = true } diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 28197f3923e..cb7cbf71a81 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -906,6 +906,27 @@ impl BuzzClient { .await } + /// Fetch capability and identity only from the configured NIP-11 origin. + /// Redirects cannot retarget trust or a pending channel-label command. + pub(crate) async fn nip11_for_channel_labels(&self) -> Result { + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(env_duration_secs("BUZZ_TIMEOUT_SECS", 30)) + .build()?; + let response = http + .get(format!("{}/", self.relay_url)) + .header("Accept", "application/nostr+json") + .send() + .await?; + if !response.status().is_success() { + return Err(CliError::Other(format!( + "NIP-11 returned HTTP {}", + response.status() + ))); + } + Ok(response.text().await?) + } + /// Send a state-changing JSON command exactly once. Ambiguous delivery /// never invites an automatic re-run with a newly observed version. pub async fn post_json_once_authed( diff --git a/crates/buzz-cli/src/commands/channel_labels/journal.rs b/crates/buzz-cli/src/commands/channel_labels/journal.rs new file mode 100644 index 00000000000..b653bb9f18a --- /dev/null +++ b/crates/buzz-cli/src/commands/channel_labels/journal.rs @@ -0,0 +1,137 @@ +//! Append-only, fsynced exact-event recovery. A file lock excludes concurrent sends. +use std::fs::{File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::Path; + +use buzz_core::channel_labels::CommandOutcome; +use nostr::{Event, PublicKey}; +use serde::{Deserialize, Serialize}; + +use crate::error::CliError; + +const MAX_JOURNAL_BYTES: u64 = 1024 * 1024; + +#[derive(Serialize, Deserialize)] +pub(super) struct CommandRecord { + pub version: u8, + pub relay_url: String, + pub relay_key: PublicKey, + pub event: Event, +} + +pub(super) struct Journal { + file: File, + pub record: CommandRecord, + pub outcome: CommandOutcome, +} + +fn storage(error: impl std::fmt::Display) -> CliError { + CliError::Other(format!("command recovery journal: {error}")) +} + +impl Journal { + pub fn create(path: &Path, record: CommandRecord) -> Result { + let mut options = OpenOptions::new(); + options.read(true).append(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(path).map_err(storage)?; + fs2::FileExt::try_lock_exclusive(&file).map_err(storage)?; + let mut journal = Self { + file, + record, + outcome: CommandOutcome::Prepared, + }; + journal.append(&serde_json::to_vec(&journal.record).map_err(storage)?)?; + // Persist the directory entry before transport. Never create parents or + // overwrite an existing file: the caller chooses the recovery location. + #[cfg(unix)] + File::open( + path.parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")), + ) + .and_then(|dir| dir.sync_all()) + .map_err(storage)?; + Ok(journal) + } + + pub fn open(path: &Path) -> Result { + let mut file = OpenOptions::new() + .read(true) + .append(true) + .open(path) + .map_err(storage)?; + fs2::FileExt::try_lock_exclusive(&file).map_err(storage)?; + if file.metadata().map_err(storage)?.len() > MAX_JOURNAL_BYTES { + return Err(storage("file exceeds 1 MiB")); + } + file.seek(SeekFrom::Start(0)).map_err(storage)?; + let mut bytes = Vec::new(); + (&mut file) + .take(MAX_JOURNAL_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(storage)?; + if bytes.len() as u64 > MAX_JOURNAL_BYTES { + return Err(storage("file exceeds 1 MiB")); + } + let complete = bytes + .iter() + .rposition(|b| *b == b'\n') + .ok_or_else(|| storage("incomplete command record; no recoverable signed event"))?; + let mut lines = bytes[..complete].split(|b| *b == b'\n'); + let record: CommandRecord = + serde_json::from_slice(lines.next().ok_or_else(|| storage("empty journal"))?) + .map_err(storage)?; + if record.version != 1 { + return Err(storage("unsupported version")); + } + let mut outcome = CommandOutcome::Prepared; + for line in lines { + let next: CommandOutcome = serde_json::from_slice(line).map_err(storage)?; + // The pre-send Unknown entry remains authoritative if the process + // crashes before the next complete acknowledgement record. + if outcome == CommandOutcome::Committed && next != outcome { + return Err(storage("committed outcome cannot be downgraded")); + } + outcome = next; + } + let mut journal = Self { + file, + record, + outcome, + }; + if complete + 1 != bytes.len() { + // Only the final partial append is disposable. The complete signed + // event is immutable. A torn acknowledgement is not proof of rejection. + journal + .file + .set_len((complete + 1) as u64) + .map_err(storage)?; + journal.file.sync_all().map_err(storage)?; + journal.persist(outcome.before_send())?; + } + Ok(journal) + } + + pub fn persist(&mut self, outcome: CommandOutcome) -> Result<(), CliError> { + self.append(&serde_json::to_vec(&outcome).map_err(storage)?)?; + self.outcome = outcome; + Ok(()) + } + + fn append(&mut self, bytes: &[u8]) -> Result<(), CliError> { + if self.file.metadata().map_err(storage)?.len() + bytes.len() as u64 + 1 > MAX_JOURNAL_BYTES + { + return Err(storage("retry journal is full; no request was sent")); + } + self.file + .write_all(bytes) + .and_then(|_| self.file.write_all(b"\n")) + .and_then(|_| self.file.sync_all()) + .map_err(storage) + } +} diff --git a/crates/buzz-cli/src/commands/channel_labels/journal_tests.rs b/crates/buzz-cli/src/commands/channel_labels/journal_tests.rs new file mode 100644 index 00000000000..7a40d40cc64 --- /dev/null +++ b/crates/buzz-cli/src/commands/channel_labels/journal_tests.rs @@ -0,0 +1,110 @@ +use super::*; +use std::io::Write; + +fn record() -> CommandRecord { + CommandRecord { + version: 1, + relay_url: "https://tenant.example".into(), + relay_key: Keys::generate().public_key(), + event: EventBuilder::new(Kind::Custom(9002), "") + .tags([ + Tag::parse(["h", &Uuid::new_v4().to_string()]).unwrap(), + Tag::parse(["add-label", "a"]).unwrap(), + ]) + .sign_with_keys(&Keys::generate()) + .unwrap(), + } +} + +#[test] +fn journal_is_exclusive_private_and_preserves_exact_signed_event() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("command.jsonl"); + let mut journal = Journal::create(&path, record()).unwrap(); + let event = journal.record.event.clone(); + assert!(Journal::create(&path, record()).is_err()); + assert!(Journal::open(&path).is_err()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + journal.persist(CommandOutcome::Unknown).unwrap(); + drop(journal); + let journal = Journal::open(&path).unwrap(); + assert_eq!(journal.record.event, event); + assert_eq!(journal.outcome, CommandOutcome::Unknown); +} + +#[test] +fn every_partial_outcome_append_recovers_conservatively_without_resigning() { + for prior in [ + CommandOutcome::Prepared, + CommandOutcome::Unknown, + CommandOutcome::Committed, + ] { + for append in [ + b"\"unknown\"\n".as_slice(), + b"\"rejected\"\n", + b"\"committed\"\n", + ] { + for length in 1..append.len() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("command.jsonl"); + let mut journal = Journal::create(&path, record()).unwrap(); + let event = journal.record.event.clone(); + journal.persist(prior).unwrap(); + drop(journal); + std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap() + .write_all(&append[..length]) + .unwrap(); + let journal = Journal::open(&path).unwrap(); + assert_eq!(journal.record.event, event); + assert_eq!(journal.outcome, prior.before_send()); + drop(journal); + assert_eq!(Journal::open(&path).unwrap().outcome, prior.before_send()); + } + } + } +} + +#[test] +fn journal_rejects_incomplete_command_or_corrupt_complete_records() { + for suffix in ["\"committed\"\n\"rejected\"\n", "not-json\n"] { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("command.jsonl"); + drop(Journal::create(&path, record()).unwrap()); + std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap() + .write_all(suffix.as_bytes()) + .unwrap(); + assert!(Journal::open(&path).is_err()); + } + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("command.jsonl"); + std::fs::write(&path, b"{\"version\":1").unwrap(); + assert!(Journal::open(&path).is_err()); +} + +#[test] +fn journal_size_limit_preserves_record_and_refuses_new_attempt() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("command.jsonl"); + let mut journal = Journal::create(&path, record()).unwrap(); + // Simulate a full file without an unbounded retry loop. + let file = std::fs::OpenOptions::new().write(true).open(&path).unwrap(); + file.set_len(1024 * 1024).unwrap(); + assert!(journal.persist(CommandOutcome::Unknown).is_err()); + assert_eq!(journal.outcome, CommandOutcome::Prepared); + file.set_len(1024 * 1024 + 1).unwrap(); + drop(journal); + assert!(Journal::open(&path).is_err()); +} diff --git a/crates/buzz-cli/src/commands/channel_labels/mod.rs b/crates/buzz-cli/src/commands/channel_labels/mod.rs new file mode 100644 index 00000000000..e6758a4c7a3 --- /dev/null +++ b/crates/buzz-cli/src/commands/channel_labels/mod.rs @@ -0,0 +1,299 @@ +//! Capability-gated NIP-CL commands and verified reads. No current-state receipt inference. +mod journal; +#[cfg(test)] +mod tests; + +use std::path::Path; + +use buzz_core::channel_labels::{verify_snapshot, CommandOutcome, LabelCommand, LabelSet}; +use nostr::{Event, EventBuilder, EventId, Kind, PublicKey, Tag}; +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::{client::BuzzClient, error::CliError, ChannelLabelsCmd}; +use journal::{CommandRecord, Journal}; + +fn invalid(error: impl std::fmt::Display) -> CliError { + CliError::Usage(error.to_string()) +} + +async fn capability( + client: &BuzzClient, + trusted: Option, +) -> Result { + let url = url::Url::parse(client.relay_url()).map_err(invalid)?; + if url.scheme() != "https" && trusted.is_none() { + return Err(invalid( + "NIP-CL over plain HTTP requires --trusted-relay from an out-of-band source", + )); + } + let raw = client.nip11_for_channel_labels().await?; + let info: Value = serde_json::from_str(&raw).map_err(invalid)?; + if !info["supported_extensions"] + .as_array() + .is_some_and(|ext| ext.iter().any(|v| v == "nip-cl")) + { + return Err(invalid( + "target relay does not advertise nip-cl; no label command was sent", + )); + } + let relay = PublicKey::from_hex( + info["self"] + .as_str() + .ok_or_else(|| invalid("NIP-11 missing self identity"))?, + ) + .map_err(invalid)?; + if trusted.is_some_and(|key| key != relay) { + return Err(invalid( + "target relay identity changed; recovery journal and snapshots cannot be retargeted", + )); + } + Ok(relay) +} + +fn trusted_key(value: Option<&str>) -> Result, CliError> { + value.map(PublicKey::from_hex).transpose().map_err(invalid) +} + +pub(super) async fn create( + client: &BuzzClient, + builder: EventBuilder, + labels: Vec, + path: &Path, + trusted: Option<&str>, +) -> Result<(), CliError> { + // Validate raw tags through the same grammar, not only a deduplicated set. + let tags = labels + .iter() + .map(|value| Tag::parse(["label", value])) + .collect::, _>>() + .map_err(invalid)?; + prepare( + client, + client.sign_event(builder.tags(tags))?, + path, + trusted, + ) + .await +} + +async fn prepare( + client: &BuzzClient, + event: Event, + path: &Path, + trusted: Option<&str>, +) -> Result<(), CliError> { + LabelCommand::parse(&event) + .map_err(invalid)? + .ok_or_else(|| invalid("not a NIP-CL command"))?; + let relay = capability(client, trusted_key(trusted)?).await?; + let mut journal = Journal::create( + path, + CommandRecord { + version: 1, + relay_url: client.relay_url().to_owned(), + relay_key: relay, + event, + }, + )?; + send(client, &mut journal, path).await +} + +async fn send(client: &BuzzClient, journal: &mut Journal, path: &Path) -> Result<(), CliError> { + let previous = journal.outcome; + if previous != CommandOutcome::Committed { + journal.persist(previous.before_send())?; + // Exactly one attempt: transport/generic errors are Unknown, even on the + // first attempt. Retrying uses this event and a new NIP-98 auth proof. + let response = client + .post_json_once_authed("/events", &json!(journal.record.event)) + .await; + let outcome = response + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .and_then(|body| { + Some(previous.acknowledge( + journal.record.event.id, + EventId::from_hex(body["event_id"].as_str()?).ok()?, + body["accepted"].as_bool()?, + body["message"].as_str()?, + )) + }) + .unwrap_or(CommandOutcome::Unknown); + journal.persist(outcome).map_err(|e| { + CliError::DeliveryUnknown(format!( + "{e}; event {} is retained at {}", + journal.record.event.id, + path.display() + )) + })?; + } + let channel = LabelCommand::parse(&journal.record.event) + .map_err(invalid)? + .ok_or_else(|| invalid("not a NIP-CL command"))? + .channel(); + println!( + "{}", + json!({"event_id": journal.record.event.id, "channel_id":channel, + "outcome":journal.outcome, "command_file":path, "accepted":journal.outcome == CommandOutcome::Committed}) + ); + match journal.outcome { + CommandOutcome::Committed => Ok(()), + CommandOutcome::Rejected => Err(CliError::Other(format!("NIP-CL rejected new command {}; retained at {}", journal.record.event.id, path.display()))), + _ => Err(CliError::DeliveryUnknown(format!("NIP-CL outcome unknown; retry only with channels labels retry --command-file {}; do not create a new event", path.display()))), + } +} + +async fn read( + client: &BuzzClient, + relay: PublicKey, + filter: Value, + expected: Option, +) -> Result { + let raw = client.query(&filter).await?; + // Parsing and Schnorr verification scale with the result page; do not run + // that CPU work on the async executor. + tokio::task::spawn_blocking(move || verified_snapshots(&raw, relay, expected)) + .await + .map_err(|e| CliError::Other(format!("snapshot verification task failed: {e}")))? +} + +fn verified_snapshots( + raw: &str, + relay: PublicKey, + expected: Option, +) -> Result { + let events: Vec = serde_json::from_str(raw).map_err(invalid)?; + let mut current = std::collections::BTreeMap::new(); + for event in events { + let channel = event + .tags + .iter() + .find_map(|tag| { + let parts = tag.as_slice(); + (parts[0] == "d").then(|| parts.get(1)).flatten() + }) + .ok_or_else(|| invalid("metadata lacks channel identifier"))?; + let channel = Uuid::parse_str(channel).map_err(invalid)?; + if expected.is_some_and(|id| id != channel) { + return Err(invalid("metadata identifies another channel")); + } + let labels = verify_snapshot(&event, relay, channel).map_err(invalid)?; + let replace = current + .get(&channel) + .is_none_or(|(previous, _): &(Event, LabelSet)| { + event.created_at > previous.created_at + || (event.created_at == previous.created_at && event.id < previous.id) + }); + if replace { + current.insert(channel, (event, labels)); + } + } + let mut current: Vec<_> = current.into_iter().collect(); + current.sort_by(|(_, (a, _)), (_, (b, _))| { + b.created_at + .cmp(&a.created_at) + .then_with(|| a.id.cmp(&b.id)) + }); + Ok(Value::Array( + current + .into_iter() + .map(|(channel, (event, labels))| { + json!({ + "channel_id":channel, "labels":labels.values(), "event":event + }) + }) + .collect(), + )) +} + +pub(super) async fn dispatch( + command: ChannelLabelsCmd, + client: &BuzzClient, +) -> Result<(), CliError> { + match command { + ChannelLabelsCmd::Get { + channel, + trusted_relay, + } => { + let channel = crate::validate::parse_uuid(&channel)?; + let relay = capability(client, trusted_key(trusted_relay.as_deref())?).await?; + println!( + "{}", + read( + client, + relay, + json!({"kinds":[39000],"authors":[relay],"#d":[channel]}), + Some(channel) + ) + .await? + ); + Ok(()) + } + ChannelLabelsCmd::Find { + labels, + channel_type, + limit, + trusted_relay, + } => { + let labels = LabelSet::new(labels).map_err(invalid)?; + let relay = capability(client, trusted_key(trusted_relay.as_deref())?).await?; + let mut filter = json!({"kinds":[39000],"authors":[relay],"#L":["nip-cl"],"#l":labels.values(),"limit":limit}); + if let Some(kind) = channel_type { + filter["#t"] = json!([kind.to_string()]); + } + println!("{}", read(client, relay, filter, None).await?); + Ok(()) + } + ChannelLabelsCmd::Update { + channel, + add, + remove, + command_file, + trusted_relay, + } => { + let channel = crate::validate::parse_uuid(&channel)?; + let mut tags = vec![Tag::parse(["h", &channel.to_string()]).map_err(invalid)?]; + for (name, values) in [("add-label", add), ("remove-label", remove)] { + for value in values { + tags.push(Tag::parse([name, &value]).map_err(invalid)?); + } + } + let event = client.sign_event(EventBuilder::new(Kind::Custom(9002), "").tags(tags))?; + prepare(client, event, &command_file, trusted_relay.as_deref()).await + } + ChannelLabelsCmd::Retry { command_file } => { + let mut journal = Journal::open(&command_file).map_err(|e| { + CliError::DeliveryUnknown(format!( + "{e}; preserve {}; do not generate a replacement command", + command_file.display() + )) + })?; + if journal.record.relay_url != client.relay_url() + || journal.record.event.pubkey != client.keys().public_key() + { + return Err(invalid( + "command recovery is bound to its original relay URL and author", + )); + } + buzz_core::verify_event(&journal.record.event).map_err(invalid)?; + LabelCommand::parse(&journal.record.event) + .map_err(invalid)? + .ok_or_else(|| invalid("journal is not a NIP-CL command"))?; + if journal.outcome != CommandOutcome::Committed { + capability(client, Some(journal.record.relay_key)) + .await + .map_err(|e| { + if journal.outcome == CommandOutcome::Unknown { + CliError::DeliveryUnknown(format!( + "{e}; earlier application remains unknown" + )) + } else { + e + } + })?; + } + send(client, &mut journal, &command_file).await + } + } +} diff --git a/crates/buzz-cli/src/commands/channel_labels/snapshot_tests.rs b/crates/buzz-cli/src/commands/channel_labels/snapshot_tests.rs new file mode 100644 index 00000000000..46fb3ddfbe1 --- /dev/null +++ b/crates/buzz-cli/src/commands/channel_labels/snapshot_tests.rs @@ -0,0 +1,127 @@ +use super::*; +use nostr::Timestamp; + +fn snapshot(keys: &Keys, channel: Uuid, time: u64, labels: &[&str]) -> Event { + let mut tags = vec![ + Tag::parse(["d", &channel.to_string()]).unwrap(), + Tag::parse(["t", "stream"]).unwrap(), + ]; + if !labels.is_empty() { + tags.push(Tag::parse(["L", "nip-cl"]).unwrap()); + for label in labels { + tags.push(Tag::parse(["l", label, "nip-cl"]).unwrap()); + } + } + EventBuilder::new(Kind::Custom(39000), "") + .tags(tags) + .custom_created_at(Timestamp::from(time)) + .sign_with_keys(keys) + .unwrap() +} + +#[tokio::test] +async fn verified_reads_use_nip01_replacement_order_and_find_uses_generic_filters() { + let f = Fixture::new(vec![]).await; + let channel = Uuid::new_v4(); + let old = snapshot(&f.relay, channel, 10, &["a"]); + let mut tied = [ + snapshot(&f.relay, channel, 20, &["b"]), + snapshot(&f.relay, channel, 20, &["c"]), + ]; + tied.sort_by_key(|event| event.id); + let other = snapshot(&f.relay, Uuid::new_v4(), 30, &["a"]); + let events = vec![other.clone(), tied[1].clone(), tied[0].clone(), old]; + f.state.lock().unwrap().snapshots = events.clone(); + let result = read( + &f.client, + f.relay.public_key(), + json!({"kinds":[39000]}), + None, + ) + .await + .unwrap(); + assert_eq!(result.as_array().unwrap().len(), 2); + assert_eq!(result[0]["event"]["id"], json!(other.id)); + assert_eq!(result[1]["event"]["id"], json!(tied[0].id)); + assert!(read( + &f.client, + f.relay.public_key(), + json!({"kinds":[39000]}), + Some(channel) + ) + .await + .is_err()); + dispatch( + ChannelLabelsCmd::Find { + labels: vec!["a".into(), "b".into()], + channel_type: Some(crate::ChannelType::Stream), + limit: 3, + trusted_relay: Some(f.relay.public_key().to_hex()), + }, + &f.client, + ) + .await + .unwrap(); + assert_eq!( + f.state.lock().unwrap().filters.last().unwrap(), + &json!([{ + "kinds":[39000], "authors":[f.relay.public_key()], "#L":["nip-cl"], "#l":["a","b"], "#t":["stream"], "limit":3 + }]) + ); +} + +#[tokio::test] +async fn untrusted_or_noncanonical_reads_are_errors_never_empty_authoritative_labels() { + let f = Fixture::new(vec![]).await; + let channel = Uuid::new_v4(); + let valid = snapshot(&f.relay, channel, 20, &["a"]); + let mut wrong_signature = valid.clone(); + wrong_signature.content = "tampered".into(); + let mut cases = vec![ + snapshot(&Keys::generate(), channel, 20, &["a"]), + wrong_signature, + snapshot(&f.relay, channel, 20, &["z", "a"]), + snapshot(&f.relay, channel, 20, &["a", "a"]), + snapshot(&f.relay, Uuid::new_v4(), 20, &["a"]), + ]; + for malformed in [ + vec!["label", "legacy"], + vec!["l", "unmarked"], + vec!["L", "elsewhere"], + vec!["d", "duplicate"], + ] { + cases.push( + EventBuilder::new(Kind::Custom(39000), "") + .tags( + valid + .tags + .iter() + .cloned() + .chain([Tag::parse(malformed).unwrap()]), + ) + .sign_with_keys(&f.relay) + .unwrap(), + ); + } + for event in cases { + f.state.lock().unwrap().snapshots = vec![event]; + assert!(read( + &f.client, + f.relay.public_key(), + json!({"kinds":[39000]}), + Some(channel) + ) + .await + .is_err()); + } + f.state.lock().unwrap().snapshots = vec![snapshot(&f.relay, channel, 40, &[])]; + let result = read( + &f.client, + f.relay.public_key(), + json!({"kinds":[39000]}), + Some(channel), + ) + .await + .unwrap(); + assert_eq!(result[0]["labels"], json!([])); +} diff --git a/crates/buzz-cli/src/commands/channel_labels/tests.rs b/crates/buzz-cli/src/commands/channel_labels/tests.rs new file mode 100644 index 00000000000..cc57179c1df --- /dev/null +++ b/crates/buzz-cli/src/commands/channel_labels/tests.rs @@ -0,0 +1,467 @@ +//! Real CLI dispatch over a loopback HTTP transport. The server controls receipts, +//! not application state; relay/Postgres tests establish the commit contract. +use super::*; +use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use base64::Engine; +use clap::Parser; +use nostr::Keys; +use std::{ + collections::VecDeque, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +#[derive(Clone)] +struct Reply { + status: StatusCode, + accepted: bool, + message: &'static str, + wrong_id: bool, +} + +impl Reply { + fn ok(accepted: bool, message: &'static str) -> Self { + Self { + status: StatusCode::OK, + accepted, + message, + wrong_id: false, + } + } +} + +#[derive(Default)] +struct ServerState { + info: Value, + replies: VecDeque, + sent: Vec<(Vec, Event)>, + filters: Vec, + snapshots: Vec, + journal: Option, +} + +struct Fixture { + client: BuzzClient, + relay: Keys, + state: Arc>, + task: tokio::task::JoinHandle<()>, + temp: tempfile::TempDir, +} + +async fn submit( + State(state): State>>, + headers: HeaderMap, + body: Bytes, +) -> impl IntoResponse { + let event: Event = serde_json::from_slice(&body).unwrap(); + buzz_core::verify_event(&event).unwrap(); + let auth = headers["authorization"] + .to_str() + .unwrap() + .strip_prefix("Nostr ") + .unwrap(); + let auth: Event = serde_json::from_slice( + &base64::engine::general_purpose::STANDARD + .decode(auth) + .unwrap(), + ) + .unwrap(); + buzz_core::verify_event(&auth).unwrap(); + assert_eq!(auth.pubkey, event.pubkey); + let mut state = state.lock().unwrap(); + let path = state.journal.as_ref().unwrap(); + let persisted = std::fs::read_to_string(path).unwrap(); + let mut lines = persisted.lines(); + let record: CommandRecord = serde_json::from_str(lines.next().unwrap()).unwrap(); + assert_eq!( + record.event, event, + "signed command must be on disk before transport" + ); + assert_eq!( + lines.last(), + Some("\"unknown\""), + "crash-safe state must precede transport" + ); + assert!( + Journal::open(path).is_err(), + "another process must not send concurrently" + ); + state.sent.push((body.to_vec(), auth)); + let reply = state + .replies + .pop_front() + .expect("unexpected automatic send"); + ( + reply.status, + Json(json!({ + "event_id": if reply.wrong_id { EventId::from_byte_array([0; 32]) } else { event.id }, + "accepted":reply.accepted, "message":reply.message, + })), + ) +} + +impl Fixture { + async fn new(replies: Vec) -> Self { + let relay = Keys::generate(); + let state = Arc::new(Mutex::new(ServerState { + info: json!({"supported_extensions":["nip-cl"],"self":relay.public_key()}), + replies: replies.into(), + ..Default::default() + })); + let app = Router::new() + .route("/", get(|State(s): State>>| async move { + Json(s.lock().unwrap().info.clone()) + })) + .route("/events", post(submit)) + .route("/query", post(|State(s): State>>, Json(filter): Json| async move { + let mut s = s.lock().unwrap(); + s.filters.push(filter); + Json(s.snapshots.clone()) + })) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + Self { + client: BuzzClient::new(url, Keys::generate(), None, None).unwrap(), + relay, + state, + task, + temp: tempfile::tempdir().unwrap(), + } + } + + fn path(&self) -> PathBuf { + self.temp.path().join("command.jsonl") + } + + async fn update(&self) -> Result<(), CliError> { + self.state.lock().unwrap().journal = Some(self.path()); + dispatch( + ChannelLabelsCmd::Update { + channel: Uuid::new_v4().to_string(), + add: vec!["team:infra".into()], + remove: vec![], + command_file: self.path(), + trusted_relay: Some(self.relay.public_key().to_hex()), + }, + &self.client, + ) + .await + } + + async fn retry(&self) -> Result<(), CliError> { + dispatch( + ChannelLabelsCmd::Retry { + command_file: self.path(), + }, + &self.client, + ) + .await + } + + fn outcome(&self) -> CommandOutcome { + Journal::open(&self.path()).unwrap().outcome + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.task.abort(); + } +} + +#[tokio::test] +async fn lost_ack_then_rejection_stays_unknown_until_exact_positive_receipt() { + let f = Fixture::new(vec![ + Reply { + status: StatusCode::BAD_GATEWAY, + ..Reply::ok(false, "proxy lost ack") + }, + Reply::ok( + false, + "restricted: nip-cl-rejected access revoked or expired", + ), + Reply::ok(true, "duplicate: nip-cl-committed"), + ]) + .await; + assert!(matches!( + f.update().await, + Err(CliError::DeliveryUnknown(_)) + )); + assert_eq!(f.outcome(), CommandOutcome::Unknown); + let before = Journal::open(&f.path()).unwrap().record.event; + assert!(matches!(f.retry().await, Err(CliError::DeliveryUnknown(_)))); + assert_eq!(f.outcome(), CommandOutcome::Unknown); + f.retry().await.unwrap(); + assert_eq!(f.outcome(), CommandOutcome::Committed); + // Known commit is terminal even after capability removal; no additional send. + f.state.lock().unwrap().info = json!({}); + f.retry().await.unwrap(); + let state = f.state.lock().unwrap(); + assert_eq!(state.sent.len(), 3); + for pair in state.sent.windows(2) { + assert_eq!( + pair[0].0, pair[1].0, + "exact body across process-style reopen" + ); + assert_ne!( + pair[0].1.id, pair[1].1.id, + "fresh NIP-98 proof, same command" + ); + } + assert_eq!(before, Journal::open(&f.path()).unwrap().record.event); + assert!( + state.filters.is_empty(), + "current labels never establish a receipt" + ); +} + +#[tokio::test] +async fn first_attempt_receipts_are_classified_without_automatic_transport_retries() { + let mut cases = vec![ + (Reply::ok(true, ""), CommandOutcome::Committed), + ( + Reply::ok(false, "error: nip-cl-unknown"), + CommandOutcome::Unknown, + ), + ( + Reply::ok(false, "restricted: denied"), + CommandOutcome::Unknown, + ), + ( + Reply::ok(false, "invalid: nip-cl-rejected-extra"), + CommandOutcome::Unknown, + ), + ( + Reply { + wrong_id: true, + ..Reply::ok(true, "") + }, + CommandOutcome::Unknown, + ), + ]; + for prefix in [ + "invalid", + "auth-required", + "restricted", + "duplicate", + "rate-limited", + "error", + ] { + // The message is a static test fixture, bounded to six strings. + let message = match prefix { + "invalid" => "invalid: nip-cl-rejected", + "auth-required" => "auth-required: nip-cl-rejected reason", + "restricted" => "restricted: nip-cl-rejected reason", + "duplicate" => "duplicate: nip-cl-rejected reason", + "rate-limited" => "rate-limited: nip-cl-rejected reason", + _ => "error: nip-cl-rejected reason", + }; + cases.push((Reply::ok(false, message), CommandOutcome::Rejected)); + } + for status in [ + StatusCode::TEMPORARY_REDIRECT, + StatusCode::UNAUTHORIZED, + StatusCode::TOO_MANY_REQUESTS, + StatusCode::INTERNAL_SERVER_ERROR, + ] { + cases.push(( + Reply { + status, + ..Reply::ok(false, "generic transport error") + }, + CommandOutcome::Unknown, + )); + } + for (reply, expected) in cases { + let f = Fixture::new(vec![reply]).await; + let result = f.update().await; + assert_eq!(f.outcome(), expected, "{result:?}"); + assert_eq!(result.is_ok(), expected == CommandOutcome::Committed); + assert_eq!( + matches!(result, Err(CliError::DeliveryUnknown(_))), + expected == CommandOutcome::Unknown + ); + assert_eq!(f.state.lock().unwrap().sent.len(), 1); + } +} + +#[tokio::test] +async fn capabilities_and_recovery_scope_fail_closed_before_send() { + let f = Fixture::new(vec![Reply::ok(false, "error: nip-cl-unknown")]).await; + assert!( + capability(&f.client, None).await.is_err(), + "plain HTTP needs explicit trust" + ); + f.state.lock().unwrap().info["supported_extensions"] = json!([]); + assert!(f.update().await.is_err()); + assert!(!f.path().exists()); + f.state.lock().unwrap().info["supported_extensions"] = json!(["nip-cl"]); + f.update().await.unwrap_err(); + let other_author = + BuzzClient::new(f.client.relay_url().into(), Keys::generate(), None, None).unwrap(); + let other_host = BuzzClient::new( + f.client.relay_url().replace("127.0.0.1", "localhost"), + f.client.keys().clone(), + None, + None, + ) + .unwrap(); + for client in [&other_author, &other_host] { + assert!(dispatch( + ChannelLabelsCmd::Retry { + command_file: f.path() + }, + client + ) + .await + .is_err()); + } + f.state.lock().unwrap().info["self"] = json!(Keys::generate().public_key()); + assert!(matches!(f.retry().await, Err(CliError::DeliveryUnknown(_)))); + assert_eq!(f.outcome(), CommandOutcome::Unknown); + assert_eq!(f.state.lock().unwrap().sent.len(), 1); +} + +#[tokio::test] +async fn labeled_and_unlabeled_create_use_the_persisted_cli_path() { + for labels in [ + vec![], + vec!["workflow/build".to_owned(), "team:infra".to_owned()], + ] { + let f = Fixture::new(vec![Reply::ok(true, "")]).await; + f.state.lock().unwrap().journal = Some(f.path()); + super::super::channels::dispatch( + crate::ChannelsCmd::Create { + name: "builds".into(), + channel_type: Some(crate::ChannelType::Forum), + visibility: Some(crate::ChannelVisibility::Private), + description: Some("CI".into()), + ttl: None, + template: None, + templates_file: None, + labels: labels.clone(), + command_file: Some(f.path()), + trusted_relay: Some(f.relay.public_key().to_hex()), + }, + &f.client, + &crate::OutputFormat::Json, + ) + .await + .unwrap(); + let record = Journal::open(&f.path()).unwrap(); + let Some(LabelCommand::Create { labels: actual, .. }) = + LabelCommand::parse(&record.record.event).unwrap() + else { + panic!("not creation") + }; + assert_eq!(actual, LabelSet::new(labels).unwrap()); + assert_eq!(record.outcome, CommandOutcome::Committed); + } +} + +#[test] +fn cli_requires_recovery_location_and_excludes_template_composition() { + let channel = Uuid::new_v4().to_string(); + for args in [ + vec![ + "buzz", + "channels", + "labels", + "update", + "--channel", + &channel, + "--add-label", + "a", + "--command-file", + "a.jsonl", + ], + vec![ + "buzz", + "channels", + "labels", + "update", + "--channel", + &channel, + "--remove-label", + "a", + "--command-file", + "a.jsonl", + ], + vec![ + "buzz", "channels", "labels", "find", "--label", "a", "--label", "b", "--type", + "stream", + ], + vec![ + "buzz", + "channels", + "labels", + "retry", + "--command-file", + "a.jsonl", + ], + ] { + assert!(crate::Cli::try_parse_from(args).is_ok()); + } + for args in [ + vec![ + "buzz", + "channels", + "labels", + "update", + "--channel", + &channel, + "--add-label", + "a", + ], + vec![ + "buzz", + "channels", + "labels", + "update", + "--channel", + &channel, + "--command-file", + "a.jsonl", + ], + vec![ + "buzz", + "channels", + "create", + "--name", + "x", + "--type", + "stream", + "--visibility", + "open", + "--label", + "a", + ], + vec![ + "buzz", + "channels", + "create", + "--name", + "x", + "--template", + "test", + "--command-file", + "a.jsonl", + ], + ] { + assert!(crate::Cli::try_parse_from(args).is_err()); + } +} + +#[path = "journal_tests.rs"] +mod journal_tests; +#[path = "snapshot_tests.rs"] +mod snapshot_tests; diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index f956d0f7f9d..d6ae35d5fd9 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -1867,6 +1867,7 @@ pub async fn dispatch( ) -> Result<(), CliError> { use crate::ChannelsCmd; match cmd { + ChannelsCmd::Labels { command } => super::channel_labels::dispatch(command, client).await, ChannelsCmd::List { visibility, member, @@ -1890,6 +1891,9 @@ pub async fn dispatch( ttl, template, templates_file, + labels, + command_file, + trusted_relay, } => { if let Some(template_name) = template { cmd_create_channel_from_template( @@ -1910,15 +1914,42 @@ pub async fn dispatch( channel_type.ok_or_else(|| CliError::Usage("--type is required".into()))?; let visibility = visibility.ok_or_else(|| CliError::Usage("--visibility is required".into()))?; - cmd_create_channel( - client, - &name, - &channel_type.to_string(), - &visibility.to_string(), - description.as_deref(), - ttl, - ) - .await + if let Some(path) = command_file { + let channel = Uuid::new_v4(); + let builder = buzz_sdk::build_create_channel( + channel, + &name, + Some(match visibility { + crate::ChannelVisibility::Open => buzz_sdk::Visibility::Open, + crate::ChannelVisibility::Private => buzz_sdk::Visibility::Private, + }), + Some(match channel_type { + crate::ChannelType::Stream => buzz_sdk::ChannelKind::Stream, + crate::ChannelType::Forum => buzz_sdk::ChannelKind::Forum, + }), + description.as_deref(), + ttl.map(validate_ttl_seconds).transpose()?, + ) + .map_err(|e| CliError::Usage(e.to_string()))?; + super::channel_labels::create( + client, + builder, + labels, + &path, + trusted_relay.as_deref(), + ) + .await + } else { + cmd_create_channel( + client, + &name, + &channel_type.to_string(), + &visibility.to_string(), + description.as_deref(), + ttl, + ) + .await + } } } ChannelsCmd::Update { diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index fed35ec201e..6b8abe30590 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod agents; +pub(crate) mod channel_labels; pub mod channel_templates; pub mod channels; pub mod dms; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index c2f6b7e089c..c10e0695d40 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -569,8 +569,54 @@ pub enum MessagesCmd { }, } +/// NIP-CL operations. Recovery never creates a replacement event ID. +#[derive(Subcommand)] +pub enum ChannelLabelsCmd { + /// Read the authorized, verified current label snapshot + Get { + #[arg(long)] + channel: String, + #[arg(long)] + trusted_relay: Option, + }, + /// Find authorized channels matching any supplied label (not a uniqueness check) + Find { + #[arg(long = "label", required = true)] + labels: Vec, + #[arg(long = "type", value_enum)] + channel_type: Option, + #[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u32).range(1..=500))] + limit: u32, + #[arg(long)] + trusted_relay: Option, + }, + /// Add/remove labels without changing other metadata + Update { + #[arg(long)] + channel: String, + #[arg(long = "add-label", required_unless_present = "remove")] + add: Vec, + #[arg(long = "remove-label", required_unless_present = "add")] + remove: Vec, + #[arg(long)] + command_file: std::path::PathBuf, + #[arg(long)] + trusted_relay: Option, + }, + /// Resubmit the persisted signed event; never infer a receipt from current labels + Retry { + #[arg(long)] + command_file: std::path::PathBuf, + }, +} + #[derive(Subcommand)] pub enum ChannelsCmd { + /// Read, discover, mutate, or retry shared channel labels (NIP-CL) + Labels { + #[command(subcommand)] + command: ChannelLabelsCmd, + }, /// List channels visible to the current identity #[command( after_help = "Examples:\n buzz channels list\n buzz channels list --visibility open" @@ -641,6 +687,15 @@ pub enum ChannelsCmd { /// app's prod app-data dir). Mainly for the dev store or testing. #[arg(long, value_name = "PATH")] templates_file: Option, + /// Initial shared label; repeat for multiple values. Requires NIP-CL. + #[arg(long = "label", requires = "command_file", conflicts_with = "template")] + labels: Vec, + /// Persist the exact signed creation for safe retry. Also opts unlabeled creation into NIP-CL. + #[arg(long, conflicts_with = "template")] + command_file: Option, + /// Explicitly trusted relay key (required for NIP-CL over plain HTTP). + #[arg(long, requires = "command_file")] + trusted_relay: Option, }, /// Update channel name, description, visibility, or ephemeral TTL #[command( @@ -2461,6 +2516,7 @@ mod tests { "delete", "get", "join", + "labels", "leave", "list", "members", @@ -2581,7 +2637,7 @@ mod tests { let expected: Vec<(&str, usize)> = vec![ ("agents", 5), ("canvas", 4), - ("channels", 16), + ("channels", 17), ("dms", 4), ("emoji", 5), ("feed", 1), diff --git a/crates/buzz-core/src/channel_labels/command.rs b/crates/buzz-core/src/channel_labels/command.rs new file mode 100644 index 00000000000..b08eca0fa5c --- /dev/null +++ b/crates/buzz-core/src/channel_labels/command.rs @@ -0,0 +1,124 @@ +use std::collections::BTreeSet; + +use nostr::Event; +use uuid::Uuid; + +use super::{validate_value, LabelError, LabelSet, MAX_OPERATION_TAGS}; +use crate::kind::{event_kind_u32, KIND_NIP29_CREATE_GROUP, KIND_NIP29_EDIT_METADATA}; + +/// A covered NIP-CL command. Parsing is not authorization or proof of application. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LabelCommand { + /// All h-tagged creates, even those with an empty initial label set. + Create { + /// Client-selected, non-nil channel UUID. + channel: Uuid, + /// Initial canonical set. + labels: LabelSet, + }, + /// A label-only incremental mutation. + Mutate { + /// Target channel UUID. + channel: Uuid, + /// Distinct additions (up to the raw operation limit). + add: BTreeSet, + /// Distinct removals, disjoint from additions. + remove: BTreeSet, + }, +} + +impl LabelCommand { + /// Parse covered commands and reject misplaced label/snapshot tags on 9007/9002. + /// + /// `None` means an ordinary metadata command or a legacy unlabeled no-h + /// create. This classification must run before generic event deduplication. + pub fn parse(event: &Event) -> Result, LabelError> { + let kind = event_kind_u32(event); + if !matches!(kind, KIND_NIP29_CREATE_GROUP | KIND_NIP29_EDIT_METADATA) { + return Ok(None); + } + let create = kind == KIND_NIP29_CREATE_GROUP; + let mut identifiers = Vec::new(); + let mut add = BTreeSet::new(); + let mut remove = BTreeSet::new(); + let mut raw_count = 0; + for tag in event.tags.iter() { + let parts = tag.as_slice(); + let name = parts[0].as_str(); + if matches!(name, "l" | "L" | "t") + || (create && matches!(name, "add-label" | "remove-label")) + || (!create && name == "label") + { + return Err(LabelError("misplaced label or snapshot tag")); + } + if name == "h" { + identifiers.push(parts); + } + if matches!(name, "label" | "add-label" | "remove-label") { + raw_count += 1; + if raw_count > MAX_OPERATION_TAGS { + return Err(LabelError("too many raw label operations")); + } + if parts.len() != 2 { + return Err(LabelError("label operation requires exactly two elements")); + } + validate_value(&parts[1])?; + if name == "remove-label" { + remove.insert(parts[1].clone()); + } else { + add.insert(parts[1].clone()); + } + } + } + if raw_count == 0 && (!create || identifiers.is_empty()) { + return Ok(None); + } + let channel = match identifiers.as_slice() { + [parts] if parts.len() == 2 => Uuid::parse_str(&parts[1]) + .ok() + .filter(|id| !id.is_nil()) + .ok_or(LabelError("h must identify a valid non-nil channel UUID"))?, + _ => return Err(LabelError("command requires exactly one two-element h tag")), + }; + if create { + return Ok(Some(Self::Create { + channel, + labels: LabelSet::new(add)?, + })); + } + // Unknown metadata cannot slip through the weaker topic/member branch. + // Envelope/authentication tags have no metadata-changing semantics. + if !event.content.is_empty() + || event.tags.iter().any(|tag| { + !matches!( + tag.as_slice()[0].as_str(), + "h" | "add-label" + | "remove-label" + | "auth" + | "client" + | "client-id" + | "nonce" + | "expiration" + | "-" + ) + }) + { + return Err(LabelError("label mutations must be label-only")); + } + if !add.is_disjoint(&remove) { + return Err(LabelError("a label cannot be both added and removed")); + } + Ok(Some(Self::Mutate { + channel, + add, + remove, + })) + } + + /// The channel named by the command's single validated `h` tag. + pub fn channel(&self) -> Uuid { + match self { + Self::Create { channel, .. } | Self::Mutate { channel, .. } => *channel, + } + } +} diff --git a/crates/buzz-core/src/channel_labels/mod.rs b/crates/buzz-core/src/channel_labels/mod.rs new file mode 100644 index 00000000000..ba9cadb3e81 --- /dev/null +++ b/crates/buzz-core/src/channel_labels/mod.rs @@ -0,0 +1,103 @@ +//! NIP-CL channel labels: bounded values, command grammar and trusted snapshots. +//! +//! This module has no I/O. Authorization, serialization and capability admission +//! belong to the relay; these types do not confer authority. + +mod command; +mod outcome; +mod snapshot; + +pub use command::LabelCommand; +pub use outcome::CommandOutcome; +pub use snapshot::verify_snapshot; + +use std::collections::BTreeSet; + +/// NIP-32 namespace reserved for the channel label projection. +pub const NAMESPACE: &str = "nip-cl"; +/// Maximum number of distinct labels stored on one channel. +pub const MAX_LABELS: usize = 32; +/// Maximum ASCII bytes per label. +pub const MAX_LABEL_BYTES: usize = 64; +/// Maximum raw operation tags, before deduplication. +pub const MAX_OPERATION_TAGS: usize = 64; + +/// A violation of the NIP-CL wire contract. Details never contain stored state. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{0}")] +pub struct LabelError(pub &'static str); + +/// A canonical, ASCII-sorted, duplicate-free bounded set of channel labels. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LabelSet(Vec); + +impl LabelSet { + /// Validate values without normalization, deduplicate and sort them. + pub fn new(values: impl IntoIterator) -> Result { + let mut labels = BTreeSet::new(); + for value in values { + validate_value(&value)?; + labels.insert(value); + if labels.len() > MAX_LABELS { + return Err(LabelError("too many stored labels")); + } + } + Ok(Self(labels.into_iter().collect())) + } + + /// The complete canonical values; unknown valid values must be preserved. + pub fn values(&self) -> &[String] { + &self.0 + } + + /// Whether the set is empty (empty snapshots omit both `L` and `l`). + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Encode the single-namespace NIP-32 projection, in canonical order. + pub fn snapshot_tags(&self) -> Vec> { + if self.is_empty() { + return Vec::new(); + } + let mut tags = vec![vec!["L".into(), NAMESPACE.into()]]; + tags.extend( + self.0 + .iter() + .map(|value| vec!["l".into(), value.clone(), NAMESPACE.into()]), + ); + tags + } + + /// Apply a validated incremental command, checking the final (not interim) size. + pub fn apply(&self, command: &LabelCommand) -> Result { + match command { + LabelCommand::Create { labels, .. } => Ok(labels.clone()), + LabelCommand::Mutate { add, remove, .. } => Self::new( + self.0 + .iter() + .chain(add) + .filter(|value| !remove.contains(*value)) + .cloned(), + ), + } + } +} + +pub(super) fn validate_value(value: &str) -> Result<(), LabelError> { + let bytes = value.as_bytes(); + let alphanumeric = |byte: &u8| byte.is_ascii_lowercase() || byte.is_ascii_digit(); + if bytes.is_empty() + || bytes.len() > MAX_LABEL_BYTES + || !alphanumeric(&bytes[0]) + || !bytes + .iter() + .all(|byte| alphanumeric(byte) || b"._:/-".contains(byte)) + { + return Err(LabelError("invalid label value")); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-core/src/channel_labels/outcome.rs b/crates/buzz-core/src/channel_labels/outcome.rs new file mode 100644 index 00000000000..ed5bb751da4 --- /dev/null +++ b/crates/buzz-core/src/channel_labels/outcome.rs @@ -0,0 +1,174 @@ +//! Pure acknowledgement state. Persistence and exact-event transport belong to clients. +use nostr::EventId; +use serde::{Deserialize, Serialize}; + +/// Durable application knowledge for one signed command at one target relay. +/// +/// A client must persist the event and `Unknown` before sending it, so a crash +/// cannot make a potentially committed command look like a fresh attempt. Keep +/// the pre-attempt state in memory to classify that attempt's acknowledgement. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CommandOutcome { + /// The signed command is persisted but no delivery has been attempted. + Prepared, + /// All attempts are proven rejected before application. + Rejected, + /// At least one attempt may have committed; only a positive exact-ID OK resolves it. + Unknown, + /// The target relay positively acknowledged this exact command's application. + Committed, +} + +impl CommandOutcome { + /// State to persist before transport starts, including an exact-event retry. + /// + /// Known commit is terminal: later rejection or lost delivery cannot undo it. + pub fn before_send(self) -> Self { + if self == Self::Committed { + self + } else { + Self::Unknown + } + } + + /// Classify an OK received from the authenticated target relay. + /// + /// Call on the pre-attempt state, not the crash-safe state persisted by + /// `before_send`. A mismatched ID or generic negative acknowledgement proves + /// nothing. Readback of current labels is deliberately not an input. + pub fn acknowledge( + self, + expected: EventId, + received: EventId, + accepted: bool, + message: &str, + ) -> Self { + if self == Self::Committed { + return self; + } + if expected != received { + return Self::Unknown; + } + if accepted { + return Self::Committed; + } + if self != Self::Unknown && is_rejected(message) { + Self::Rejected + } else { + Self::Unknown + } + } +} + +fn is_rejected(message: &str) -> bool { + [ + "invalid: nip-cl-rejected", + "auth-required: nip-cl-rejected", + "restricted: nip-cl-rejected", + "duplicate: nip-cl-rejected", + "rate-limited: nip-cl-rejected", + "error: nip-cl-rejected", + ] + .iter() + .any(|prefix| { + message == *prefix + || message + .strip_prefix(prefix) + .is_some_and(|tail| tail.starts_with(' ')) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn application_knowledge_is_monotonic_across_loss_and_rejection() { + let id = EventId::from_byte_array([1; 32]); + let wrong_id = EventId::from_byte_array([2; 32]); + for previous in [ + CommandOutcome::Prepared, + CommandOutcome::Rejected, + CommandOutcome::Unknown, + CommandOutcome::Committed, + ] { + assert_eq!( + previous.before_send(), + if previous == CommandOutcome::Committed { + previous + } else { + CommandOutcome::Unknown + } + ); + for message in ["", "duplicate: nip-cl-committed", "future positive detail"] { + assert_eq!( + previous.acknowledge(id, id, true, message), + CommandOutcome::Committed + ); + assert_eq!( + previous.acknowledge(id, wrong_id, true, message), + if previous == CommandOutcome::Committed { + previous + } else { + CommandOutcome::Unknown + } + ); + } + for category in [ + "invalid", + "auth-required", + "restricted", + "duplicate", + "rate-limited", + "error", + ] { + for suffix in ["", " reason"] { + let message = format!("{category}: nip-cl-rejected{suffix}"); + let expected = match previous { + CommandOutcome::Prepared | CommandOutcome::Rejected => { + CommandOutcome::Rejected + } + other => other, + }; + assert_eq!(previous.acknowledge(id, id, false, &message), expected); + } + } + for message in [ + "invalid: stale timestamp", + "error: nip-cl-unknown", + "restricted: nip-cl-rejected-extra", + "INVALID: nip-cl-rejected", + "invalid: nip-cl-rejected\nreason", + "duplicate: nip-cl-committed", + "", + ] { + assert_eq!( + previous.acknowledge(id, id, false, message), + if previous == CommandOutcome::Committed { + previous + } else { + CommandOutcome::Unknown + }, + "{previous:?}: {message}" + ); + } + } + } + + #[test] + fn a_crashed_attempt_cannot_be_reclassified_as_a_rejected_new_command() { + let id = EventId::from_byte_array([1; 32]); + let persisted = serde_json::to_string(&CommandOutcome::Prepared.before_send()).unwrap(); + let recovered: CommandOutcome = serde_json::from_str(&persisted).unwrap(); + assert_eq!( + recovered.acknowledge(id, id, false, "restricted: nip-cl-rejected expired"), + CommandOutcome::Unknown + ); + let committed = recovered.acknowledge(id, id, true, "duplicate: nip-cl-committed"); + assert_eq!( + committed.acknowledge(id, id, false, "error: nip-cl-unknown"), + CommandOutcome::Committed + ); + } +} diff --git a/crates/buzz-core/src/channel_labels/snapshot.rs b/crates/buzz-core/src/channel_labels/snapshot.rs new file mode 100644 index 00000000000..6c367028d9a --- /dev/null +++ b/crates/buzz-core/src/channel_labels/snapshot.rs @@ -0,0 +1,79 @@ +use nostr::{Event, PublicKey}; +use uuid::Uuid; + +use super::{validate_value, LabelError, LabelSet, MAX_LABELS, NAMESPACE}; +use crate::channel::ChannelType; + +/// Verify the complete NIP-CL snapshot against an explicitly trusted relay key. +/// +/// The caller obtains `relay` from authenticated NIP-11 `self` or trusted +/// configuration, never from the snapshot. Scope caches by community and relay, +/// and use NIP-01 timestamp/lowest-ID ordering before replacing cached state. +/// CPU-bound signature verification belongs on a blocking thread in async code. +/// Invalid input is an error, never an authoritative empty label set. +pub fn verify_snapshot( + event: &Event, + relay: PublicKey, + expected_channel: Uuid, +) -> Result { + if crate::kind::event_kind_u32(event) != crate::kind::KIND_NIP29_GROUP_METADATA + || event.pubkey != relay + || expected_channel.is_nil() + || crate::verify_event(event).is_err() + { + return Err(LabelError("untrusted channel metadata snapshot")); + } + let mut identifiers = Vec::new(); + let mut types = Vec::new(); + let mut namespace_count = 0; + let mut values = Vec::new(); + for tag in event.tags.iter() { + let parts = tag.as_slice(); + match parts[0].as_str() { + "d" => identifiers.push(parts), + "t" => types.push(parts), + "label" => return Err(LabelError("legacy labels are not canonical snapshots")), + "L" => { + if parts.len() != 2 || parts[1] != NAMESPACE { + return Err(LabelError("invalid label namespace")); + } + namespace_count += 1; + } + "l" => { + if parts.len() != 3 || parts[2] != NAMESPACE { + return Err(LabelError("invalid namespaced label")); + } + validate_value(&parts[1])?; + if values.last().is_some_and(|previous| previous >= &parts[1]) { + return Err(LabelError("snapshot labels must be sorted and unique")); + } + values.push(parts[1].clone()); + if values.len() > MAX_LABELS { + return Err(LabelError("too many stored labels")); + } + } + _ => {} + } + } + let expected_id = expected_channel.to_string(); + if !matches!(identifiers.as_slice(), [parts] if parts.len() == 2 && parts[1] == expected_id) { + return Err(LabelError( + "snapshot does not identify the expected channel", + )); + } + let channel_type: ChannelType = match types.as_slice() { + [parts] if parts.len() == 2 => parts[1] + .parse() + .map_err(|_| LabelError("invalid snapshot channel type"))?, + _ => return Err(LabelError("snapshot requires exactly one channel type")), + }; + if namespace_count != usize::from(!values.is_empty()) { + return Err(LabelError( + "snapshot namespace must occur once for nonempty labels only", + )); + } + if !values.is_empty() && !matches!(channel_type, ChannelType::Stream | ChannelType::Forum) { + return Err(LabelError("channel type does not support labels")); + } + LabelSet::new(values) +} diff --git a/crates/buzz-core/src/channel_labels/tests.rs b/crates/buzz-core/src/channel_labels/tests.rs new file mode 100644 index 00000000000..510f5c24447 --- /dev/null +++ b/crates/buzz-core/src/channel_labels/tests.rs @@ -0,0 +1,300 @@ +use super::*; +use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, Tag}; +use uuid::Uuid; + +fn event(kind: u16, tags: Vec>, keys: &Keys) -> Event { + EventBuilder::new(Kind::Custom(kind), "") + .tags( + tags.into_iter() + .map(|parts| Tag::parse(parts).expect("tag")), + ) + .sign_with_keys(keys) + .expect("sign") +} + +fn tag(name: &str, value: &str) -> Vec { + vec![name.into(), value.into()] +} + +fn command(kind: u16, operations: Vec>) -> Event { + let mut tags = vec![tag("h", &Uuid::new_v4().to_string())]; + tags.extend(operations); + event(kind, tags, &Keys::generate()) +} + +#[test] +fn values_are_bounded_ascii_and_never_normalized() { + for valid in [ + "a", + "0", + "team:infra", + "workflow/build", + "a._:/-9", + "stream", + ] { + assert!(validate_value(valid).is_ok(), "{valid}"); + } + assert!(validate_value(&"a".repeat(64)).is_ok()); + for invalid in [ + "", "UPPER", " a", "a ", "a\n", "a\0", "é", "a💀", "-a", ".a", ":a", "a@b", + ] { + assert!(validate_value(invalid).is_err(), "{invalid:?}"); + } + assert!(validate_value(&"a".repeat(65)).is_err()); + for byte in 0..=255u8 { + if let Ok(value) = String::from_utf8(vec![b'a', byte]) { + assert_eq!( + validate_value(&value).is_ok(), + byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._:/-".contains(&byte) + ); + } + } +} + +#[test] +fn sets_deduplicate_sort_and_enforce_distinct_limit() { + assert_eq!( + LabelSet::new(["z".into(), "a".into(), "a".into()]) + .unwrap() + .values(), + &["a", "z"] + ); + assert!(LabelSet::new((0..32).map(|i| format!("label:{i}"))).is_ok()); + assert!(LabelSet::new((0..33).map(|i| format!("label:{i}"))).is_err()); + assert!(LabelSet::default().snapshot_tags().is_empty()); + assert_eq!( + LabelSet::new(["stream".into()]).unwrap().snapshot_tags(), + vec![vec!["L", "nip-cl"], vec!["l", "stream", "nip-cl"]] + ); +} + +#[test] +fn raw_limit_is_before_deduplication() { + for kind in [9002, 9007] { + let name = if kind == 9007 { "label" } else { "add-label" }; + assert!(LabelCommand::parse(&command(kind, vec![tag(name, "a"); 64])).is_ok()); + assert!(LabelCommand::parse(&command(kind, vec![tag(name, "a"); 65])).is_err()); + for malformed in [ + vec![name.to_string()], + vec![name.into(), "a".into(), "extra".into()], + ] { + assert!(LabelCommand::parse(&command(kind, vec![malformed])).is_err()); + } + } +} + +#[test] +fn classify_legacy_create_ordinary_metadata_and_unlabeled_h_create() { + let keys = Keys::generate(); + assert_eq!( + LabelCommand::parse(&event(9007, vec![tag("name", "legacy")], &keys)), + Ok(None) + ); + assert_eq!( + LabelCommand::parse(&command(9002, vec![tag("topic", "hello")])), + Ok(None) + ); + assert!(matches!(LabelCommand::parse(&command(9007, vec![])), + Ok(Some(LabelCommand::Create { labels, .. })) if labels.is_empty())); + assert_eq!( + LabelCommand::parse(&command(9, vec![tag("t", "free")])), + Ok(None) + ); +} + +#[test] +fn misplaced_tags_fail_even_without_valid_operations() { + for kind in [9002, 9007] { + let misplaced = if kind == 9007 { + vec!["add-label", "remove-label", "l", "L", "t"] + } else { + vec!["label", "l", "L", "t"] + }; + for name in misplaced { + assert!( + LabelCommand::parse(&command(kind, vec![tag(name, "a")])).is_err(), + "{kind} {name}" + ); + } + } +} + +#[test] +fn covered_commands_require_one_exact_non_nil_h_tag() { + for kind in [9002, 9007] { + let operation = tag(if kind == 9007 { "label" } else { "add-label" }, "a"); + for ids in [ + vec![], + vec![vec!["h".into()]], + vec![tag("h", "bad")], + vec![tag("h", &Uuid::nil().to_string())], + vec![tag("h", &Uuid::new_v4().to_string()); 2], + vec![vec!["h".into(), Uuid::new_v4().to_string(), "extra".into()]], + ] { + let mut tags = ids; + tags.push(operation.clone()); + assert!(LabelCommand::parse(&event(kind, tags, &Keys::generate())).is_err()); + } + } +} + +#[test] +fn label_mutations_reject_metadata_unknowns_overlap_and_content() { + for name in [ + "name", + "about", + "visibility", + "archived", + "topic", + "purpose", + "ttl", + "channel_type", + "unknown", + "p", + ] { + assert!( + LabelCommand::parse(&command(9002, vec![tag("add-label", "a"), tag(name, "x")])) + .is_err(), + "{name}" + ); + } + assert!(LabelCommand::parse(&command( + 9002, + vec![tag("add-label", "a"), tag("remove-label", "a")] + )) + .is_err()); + let mut has_content = command(9002, vec![tag("add-label", "a")]); + has_content.content = "metadata".into(); + assert!(LabelCommand::parse(&has_content).is_err()); + for name in ["auth", "client", "client-id", "nonce", "expiration", "-"] { + assert!( + LabelCommand::parse(&command(9002, vec![tag("add-label", "a"), tag(name, "x")])) + .is_ok() + ); + } +} + +#[test] +fn mutation_uses_final_size_and_preserves_unrelated_values() { + let current = LabelSet::new((0..32).map(|i| format!("label:{i}"))).unwrap(); + let parse = |ops| LabelCommand::parse(&command(9002, ops)).unwrap().unwrap(); + let changed = current + .apply(&parse(vec![ + tag("add-label", "new"), + tag("remove-label", "label:0"), + ])) + .unwrap(); + assert_eq!(changed.values().len(), 32); + assert!(changed.values().contains(&"label:1".into())); + assert!(changed.values().contains(&"new".into())); + assert!(!changed.values().contains(&"label:0".into())); + assert!(current + .apply(&parse(vec![tag("add-label", "new")])) + .is_err()); + assert_eq!( + current + .apply(&parse(vec![ + tag("add-label", "label:0"), + tag("remove-label", "absent") + ])) + .unwrap(), + current + ); + // More than 32 absent removals are valid: only the resulting set is bounded. + assert_eq!( + current + .apply(&parse( + (0..64) + .map(|i| tag("remove-label", &format!("absent:{i}"))) + .collect() + )) + .unwrap(), + current + ); +} + +fn snapshot_tags(channel: Uuid) -> Vec> { + vec![tag("d", &channel.to_string()), tag("t", "stream")] +} + +#[test] +fn snapshots_validate_trust_and_complete_canonical_representation() { + let keys = Keys::generate(); + let channel = Uuid::new_v4(); + let labels = LabelSet::new(["a".into(), "stream".into()]).unwrap(); + let mut tags = snapshot_tags(channel); + tags.extend(labels.snapshot_tags()); + let valid = event(39000, tags.clone(), &keys); + assert_eq!( + verify_snapshot(&valid, keys.public_key(), channel), + Ok(labels) + ); + assert!(verify_snapshot(&valid, Keys::generate().public_key(), channel).is_err()); + assert!(verify_snapshot(&valid, keys.public_key(), Uuid::new_v4()).is_err()); + for (field, value) in [ + ("content", serde_json::json!("tampered")), + ("sig", serde_json::json!("0".repeat(128))), + ] { + let mut json = serde_json::to_value(&valid).unwrap(); + json[field] = value; + let corrupted = Event::from_json(json.to_string()).unwrap(); + assert!(verify_snapshot(&corrupted, keys.public_key(), channel).is_err()); + } + for kind in [9, 9002, 39001] { + assert!(verify_snapshot( + &event(kind, tags.clone(), &keys), + keys.public_key(), + channel + ) + .is_err()); + } +} + +#[test] +fn invalid_snapshot_is_never_accepted_as_empty() { + let keys = Keys::generate(); + let channel = Uuid::new_v4(); + let l = |v: &str| vec!["l".into(), v.into(), "nip-cl".into()]; + for bad in [ + vec![tag("label", "a")], + vec![tag("L", "nip-cl")], + vec![l("a")], + vec![tag("L", "other"), l("a")], + vec![ + tag("L", "nip-cl"), + vec!["l".into(), "a".into(), "other".into()], + ], + vec![tag("L", "nip-cl"), tag("l", "a")], + vec![tag("L", "nip-cl"), l("a"), l("a")], + vec![tag("L", "nip-cl"), l("z"), l("a")], + vec![tag("L", "nip-cl"), l("A")], + vec![tag("L", "nip-cl"), tag("L", "nip-cl"), l("a")], + vec![tag("t", "forum")], + vec![tag("d", &channel.to_string())], + ] { + let mut tags = snapshot_tags(channel); + tags.extend(bad); + assert!(verify_snapshot(&event(39000, tags, &keys), keys.public_key(), channel).is_err()); + } + for channel_type in ["stream", "forum", "dm", "workflow"] { + let tags = vec![tag("d", &channel.to_string()), tag("t", channel_type)]; + assert_eq!( + verify_snapshot( + &event(39000, tags.clone(), &keys), + keys.public_key(), + channel + ), + Ok(LabelSet::default()) + ); + let mut labeled = tags; + labeled.extend([tag("L", "nip-cl"), l("a")]); + assert_eq!( + verify_snapshot(&event(39000, labeled, &keys), keys.public_key(), channel).is_ok(), + matches!(channel_type, "stream" | "forum") + ); + } + let mut too_many = snapshot_tags(channel); + too_many.push(tag("L", "nip-cl")); + too_many.extend((0..33).map(|i| l(&format!("label:{i:02}")))); + assert!(verify_snapshot(&event(39000, too_many, &keys), keys.public_key(), channel).is_err()); +} diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index e7669c874b0..2389a423497 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -9,6 +9,8 @@ pub mod agent_turn_metric; /// Channel and membership enums shared across crates. pub mod channel; +/// NIP-CL bounded shared labels, command grammar and snapshot validation. +pub mod channel_labels; /// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation, /// body parse/serialize, envelope build/validate, head selection. pub mod engram; diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index f8adfefab75..68e9c9af3bc 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -65,10 +65,10 @@ pub(crate) use runtime::{ }; pub use store::{ admin_moderation, allowlist, api_token, archived_identities, artifact, channel, - channel_members, community, deletion, dm, event, feed, git_repo, moderation, operator_listener, - partition, personal_read, product_feedback, push, reaction, read_state, relay_admin_actions, - relay_invite, relay_members, relay_operators, reminder, replaceable, storage_accounting, - thread, thread_window, usage, user, workflow, + channel_members, channel_metadata, community, deletion, dm, event, feed, git_repo, moderation, + operator_listener, partition, personal_read, product_feedback, push, reaction, read_state, + relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, replaceable, + storage_accounting, thread, thread_window, usage, user, workflow, }; pub use allowlist::AllowlistEntry; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index b6ead54e06f..7ce7023f9c6 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -705,7 +705,18 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 60); + assert_eq!(migrations.len(), 61); + assert_eq!(migrations[60].version, 61); + assert!(migrations[60] + .sql + .as_str() + .contains("ALTER TABLE channels ADD COLUMN labels")); + assert!(migrations[60] + .sql + .as_str() + .contains("ALTER TABLE events ADD COLUMN nip_cl_applied")); + assert!(!migrations[60].sql.as_str().contains("CREATE FUNCTION")); + assert!(!migrations[60].sql.as_str().contains("CREATE TRIGGER")); assert_eq!(migrations[58].version, 59); assert!(migrations[58].sql.as_str().contains("buzz.push_enabled")); assert_eq!(migrations[59].version, 60); diff --git a/crates/buzz-db/src/store/channel_metadata/activation.rs b/crates/buzz-db/src/store/channel_metadata/activation.rs new file mode 100644 index 00000000000..f807a3bbce9 --- /dev/null +++ b/crates/buzz-db/src/store/channel_metadata/activation.rs @@ -0,0 +1,74 @@ +//! Bounded startup audit for an offline NIP-CL cutover. + +use nostr::PublicKey; +use uuid::Uuid; + +use crate::{Db, DbError, Result}; +use buzz_core::CommunityId; + +impl Db { + /// Page live channel IDs for an operator's community-scoped metadata repair. + /// + /// Includes archived channels, excludes tombstones, and never uses the + /// interactive catalog's 1,000-row cap. Continue from the last returned ID. + pub async fn channel_metadata_repair_page( + &self, + community: CommunityId, + after: Uuid, + ) -> Result> { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::Bootstrap, + ) + .await?; + Ok(sqlx::query_scalar( + "SELECT id FROM channels WHERE community_id = $1 AND id > $2 \ + AND deleted_at IS NULL ORDER BY id LIMIT 128", + ) + .bind(community.as_uuid()) + .bind(after) + .fetch_all(&mut *connection) + .await?) + } + + /// Verify that every live channel has the canonical current relay projection. + /// + /// Operator-only, cross-community startup audit. Call before serving when + /// enabling NIP-CL, after old writers have been externally fenced and repair + /// has completed. This is not a fleet fence: an old binary can still corrupt + /// projections if its database access has not been revoked. Each comparison + /// uses the same scoped admitted transaction as publication; no captured + /// snapshot or read-replica state establishes readiness. + pub async fn verify_channel_metadata_activation(&self, relay: PublicKey) -> Result { + let mut cursor = (Uuid::nil(), Uuid::nil()); + let mut checked = 0; + loop { + let channels: Vec<(Uuid, Uuid)> = sqlx::query_as( + "SELECT community_id, id FROM channels WHERE deleted_at IS NULL \ + AND (community_id, id) > ($1, $2) \ + ORDER BY community_id, id LIMIT 128", + ) + .bind(cursor.0) + .bind(cursor.1) + .fetch_all(&self.pool) + .await?; + if channels.is_empty() { + return Ok(checked); + } + for (community, channel) in channels { + cursor = (community, channel); + let mut write = self + .begin_channel_metadata_write(CommunityId::from_uuid(community), channel, relay) + .await?; + let stale = write.needs_snapshot().await?; + write.rollback().await?; + if stale { + return Err(DbError::InvalidData(format!( + "NIP-CL activation requires metadata repair for community {community} channel {channel}" + ))); + } + checked += 1; + } + } + } +} diff --git a/crates/buzz-db/src/store/channel_metadata/command.rs b/crates/buzz-db/src/store/channel_metadata/command.rs new file mode 100644 index 00000000000..33fcdd19330 --- /dev/null +++ b/crates/buzz-db/src/store/channel_metadata/command.rs @@ -0,0 +1,227 @@ +use buzz_core::channel::{canonical_channel_name, ChannelType, ChannelVisibility}; +use buzz_core::channel_labels::{LabelCommand, LabelSet}; +use nostr::Event; + +use super::{read_channel, ChannelMetadataWrite}; +use crate::{DbError, Result}; + +/// Validated ordinary creation fields supplied by the relay's existing parser. +pub struct ChannelCreate { + /// Channel display name. + pub name: String, + /// Stream, forum, DM or workflow; only stream/forum may have labels. + pub channel_type: ChannelType, + /// Existing channel visibility contract. + pub visibility: ChannelVisibility, + /// Optional description. + pub description: Option, + /// Resolved ephemeral TTL, including deployment policy. + pub ttl_seconds: Option, +} + +/// A serialized command decision, before committing or rolling back the guard. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandApplication { + /// New command/evidence/state staged; caller must publish if needed and commit. + Applied, + /// Retained proof of earlier commit, after current authority/lifecycle checks. + Committed, + /// A legacy stored command has no proof of application; never reapply it. + Unknown, + /// A different create targets a used UUID (no stored metadata is disclosed). + Conflict, + /// Current authority or lifecycle denies this submission. + Restricted, + /// The command is no longer within the admission window after lock acquisition. + Expired, +} + +impl ChannelMetadataWrite { + async fn has_label_authority(&mut self, actor: &[u8]) -> Result { + // Membership writers use the lock held by this guard. Ownership is + // write-once in user::set_agent_owner; this query checks every owner. + Ok(sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM channel_members cm \ + LEFT JOIN users u ON u.community_id = cm.community_id AND u.pubkey = cm.pubkey \ + WHERE cm.community_id = $1 AND cm.channel_id = $2 AND cm.removed_at IS NULL \ + AND ((cm.pubkey = $3 AND cm.role IN ('owner', 'admin')) \ + OR (cm.role = 'owner' AND u.agent_owner_pubkey = $3)))", + ) + .bind(self.tx.community().as_uuid()) + .bind(self.channel_id) + .bind(actor) + .fetch_one(self.tx.conn()) + .await?) + } + + /// Stage one verified, admitted command on the locked channel. + /// + /// Signature, authenticated signer, host, token restrictions and feature + /// enablement must already have passed relay admission. Current timestamp, + /// channel role and lifecycle are checked again after serialization. Non-new + /// outcomes never modify state and must not dispatch or republish anything. + pub async fn apply_command( + &mut self, + event: &Event, + create: Option<&ChannelCreate>, + ) -> Result { + if self.command_applied || self.published.is_some() || self.must_rollback { + return Err(DbError::InvalidData( + "one command per metadata transaction".into(), + )); + } + // A failed statement must never leave a committable partial command. + self.must_rollback = true; + let result = self.apply_command_inner(event, create).await; + if result.is_ok() { + self.must_rollback = false; + } + result + } + + async fn apply_command_inner( + &mut self, + event: &Event, + create: Option<&ChannelCreate>, + ) -> Result { + let command = LabelCommand::parse(event) + .map_err(|e| DbError::InvalidData(e.to_string()))? + .ok_or_else(|| DbError::InvalidData("not a covered label command".into()))?; + if command.channel() != self.channel_id { + return Err(DbError::InvalidData( + "command does not match locked channel".into(), + )); + } + if event + .created_at + .as_secs() + .abs_diff(nostr::Timestamp::now().as_secs()) + > 900 + { + return Ok(CommandApplication::Expired); + } + // Soft-deleted commands retain their provenance. A current set is not a receipt. + let evidence: Option = sqlx::query_scalar( + "SELECT nip_cl_applied FROM events WHERE community_id = $1 AND id = $2 \ + AND created_at = to_timestamp($3)", + ) + .bind(self.tx.community().as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .bind(event.created_at.as_secs() as f64) + .fetch_optional(self.tx.conn()) + .await?; + if evidence.is_some() || matches!(command, LabelCommand::Mutate { .. }) { + if !self + .channel + .as_ref() + .is_some_and(|c| c.deleted_at.is_none() && c.archived_at.is_none()) + || !self.has_label_authority(event.pubkey.as_bytes()).await? + { + return Ok(CommandApplication::Restricted); + } + if let Some(committed) = evidence { + return Ok(if committed { + CommandApplication::Committed + } else { + CommandApplication::Unknown + }); + } + } + match &command { + LabelCommand::Create { labels, .. } => { + if self.channel.is_some() { + return Ok(CommandApplication::Conflict); + } + let create = + create.ok_or_else(|| DbError::InvalidData("missing creation fields".into()))?; + if !labels.is_empty() + && !matches!( + create.channel_type, + ChannelType::Stream | ChannelType::Forum + ) + { + return Err(DbError::InvalidData( + "channel type does not support labels".into(), + )); + } + self.create_channel(event, create, labels).await?; + } + LabelCommand::Mutate { .. } => { + if !matches!(self.channel()?.channel_type.as_str(), "stream" | "forum") { + return Err(DbError::InvalidData( + "channel type does not support labels".into(), + )); + } + let labels = self + .labels + .apply(&command) + .map_err(|e| DbError::InvalidData(e.to_string()))?; + if labels != self.labels { + sqlx::query("UPDATE channels SET labels = $3, updated_at = NOW() WHERE community_id = $1 AND id = $2") + .bind(self.tx.community().as_uuid()).bind(self.channel_id).bind(labels.values()) + .execute(self.tx.conn()).await?; + self.labels = labels; + } + } + } + let (_, inserted) = + crate::event::insert_event_in_transaction(&mut self.tx, event, Some(self.channel_id)) + .await?; + if !inserted { + // A competing unsupported/legacy insert is not proof of application. + // Returning an error forces the caller to discard this transaction. + return Err(DbError::InvalidData( + "command appeared without serialized evidence".into(), + )); + } + mark_command_applied(&mut self.tx, event).await?; + self.command_applied = true; + Ok(CommandApplication::Applied) + } + + async fn create_channel( + &mut self, + event: &Event, + create: &ChannelCreate, + labels: &LabelSet, + ) -> Result<()> { + let name = canonical_channel_name(&create.name); + if name.is_empty() || name.chars().count() > 255 { + return Err(DbError::InvalidData("invalid channel name".into())); + } + sqlx::query( + "INSERT INTO channels (community_id, id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline, labels) \ + VALUES ($1, $2, $3, $4::channel_type, $5::channel_visibility, $6, $7, $8, \ + CASE WHEN $8 IS NOT NULL THEN NOW() + make_interval(secs => $8) ELSE NULL END, $9)", + ).bind(self.tx.community().as_uuid()).bind(self.channel_id).bind(name) + .bind(create.channel_type.as_str()).bind(create.visibility.as_str()).bind(&create.description) + .bind(event.pubkey.as_bytes().as_slice()).bind(create.ttl_seconds).bind(labels.values()) + .execute(self.tx.conn()).await?; + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) \ + VALUES ($1, $2, $3, 'owner', $3)", + ) + .bind(self.tx.community().as_uuid()) + .bind(self.channel_id) + .bind(event.pubkey.as_bytes().as_slice()) + .execute(self.tx.conn()) + .await?; + (self.channel, self.labels) = read_channel(&mut self.tx, self.channel_id).await?; + Ok(()) + } +} + +// Only the serialized command path calls this; requiring the admitted transaction +// keeps provenance writes inside the supported-writer boundary. +async fn mark_command_applied(tx: &mut crate::AdmittedTx, event: &Event) -> Result<()> { + sqlx::query( + "UPDATE events SET nip_cl_applied = TRUE WHERE community_id = $1 AND id = $2 \ + AND created_at = to_timestamp($3)", + ) + .bind(tx.community().as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .bind(event.created_at.as_secs() as f64) + .execute(tx.conn()) + .await?; + Ok(()) +} diff --git a/crates/buzz-db/src/store/channel_metadata/mod.rs b/crates/buzz-db/src/store/channel_metadata/mod.rs new file mode 100644 index 00000000000..fd1e4e2977f --- /dev/null +++ b/crates/buzz-db/src/store/channel_metadata/mod.rs @@ -0,0 +1,166 @@ +//! Application-owned channel metadata serialization. +//! +//! One admitted transaction owns fresh channel state, current membership, +//! application provenance, and replacement publication. Signing remains with the +//! relay/operator caller. No connection or raw SQL escapes the guard. + +mod activation; +mod command; +mod snapshot; + +#[cfg(test)] +mod postgres_tests; + +use buzz_core::{channel_labels::LabelSet, CommunityId, StoredEvent}; +use nostr::PublicKey; +use sqlx::Row; +use uuid::Uuid; + +use crate::{channel::ChannelRecord, AdmittedTx, Db, DbError, Result}; + +pub use command::{ChannelCreate, CommandApplication}; + +/// A channel's fresh metadata held behind cross-replica transaction locks. +#[must_use = "dropping a metadata write rolls back all effects"] +pub struct ChannelMetadataWrite { + tx: AdmittedTx, + channel_id: Uuid, + relay: PublicKey, + channel: Option, + labels: LabelSet, + previous: Option, + published: Option, + command_applied: bool, + must_rollback: bool, +} + +impl Db { + /// Begin a serialized channel metadata write, including creation of a missing UUID. + /// + /// Lock order: admitted community -> legacy addressable coordinate -> NIP-33 + /// coordinate -> membership -> shared TTL -> channel row. Both coordinate + /// keys are necessary: existing APIs encode UUID bytes versus d-tag bytes. + pub async fn begin_channel_metadata_write( + &self, + community: CommunityId, + channel_id: Uuid, + relay: PublicKey, + ) -> Result { + if channel_id.is_nil() { + return Err(DbError::InvalidData("nil channel UUID".into())); + } + let mut tx = self.begin_event_write_transaction(community).await?; + for coordinate in [ + channel_id.as_bytes().as_slice(), + channel_id.to_string().as_bytes(), + ] { + let key = crate::replaceable::event_replacement_lock_key( + community, + 39000, + relay.as_bytes(), + Some(coordinate), + ); + crate::observability::observe_advisory_lock( + crate::observability::LockType::Replacement, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(key) + .execute(tx.conn()), + ) + .await?; + } + crate::channel_members::acquire_channel_membership_lock_in_transaction(&mut tx, channel_id) + .await?; + sqlx::query("SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))") + .bind(crate::channel::channel_ttl_lock_key(community, channel_id)) + .execute(tx.conn()) + .await?; + let (channel, labels) = read_channel(&mut tx, channel_id).await?; + let previous = sqlx::query( + "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ + FROM events WHERE community_id = $1 AND kind = 39000 AND pubkey = $2 \ + AND channel_id = $3 AND deleted_at IS NULL ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community.as_uuid()) + .bind(relay.as_bytes().as_slice()) + .bind(channel_id) + .fetch_optional(tx.conn()) + .await? + .map(crate::event::row_to_stored_event) + .transpose()? + .flatten(); + Ok(ChannelMetadataWrite { + tx, + channel_id, + relay, + channel, + labels, + previous, + published: None, + command_applied: false, + must_rollback: false, + }) + } +} + +async fn read_channel(tx: &mut AdmittedTx, id: Uuid) -> Result<(Option, LabelSet)> { + // Include deleted rows: a tombstone is not an available creation UUID. + let row = sqlx::query( + "SELECT *, channel_type::text AS channel_type, visibility::text AS visibility \ + FROM channels WHERE community_id = $1 AND id = $2 FOR NO KEY UPDATE", + ) + .bind(tx.community().as_uuid()) + .bind(id) + .fetch_optional(tx.conn()) + .await?; + match row { + Some(row) => { + let values: Vec = row.try_get("labels")?; + let labels = + LabelSet::new(values.clone()).map_err(|e| DbError::InvalidData(e.to_string()))?; + if labels.values() != values { + return Err(DbError::InvalidData("noncanonical stored labels".into())); + } + Ok((Some(crate::channel::row_to_channel_record(row)?), labels)) + } + None => Ok((None, LabelSet::default())), + } +} + +impl ChannelMetadataWrite { + /// Current labels captured under the channel lock. + pub fn labels(&self) -> &LabelSet { + &self.labels + } + + /// Current live channel, or a generic not-found error for missing/deleted rows. + pub fn channel(&self) -> Result<&ChannelRecord> { + self.channel + .as_ref() + .filter(|ch| ch.deleted_at.is_none()) + .ok_or(DbError::ChannelNotFound(self.channel_id)) + } + + /// Roll back all command, channel and snapshot changes. + pub async fn rollback(self) -> Result<()> { + self.tx.rollback().await + } + + /// Commit only after the current projection has been preserved or repaired. + /// + /// A commit error is an uncertain outcome to the caller, not proof of rejection. + /// The returned new snapshot may be dispatched only after this method succeeds. + pub async fn commit(mut self) -> Result> { + if self.must_rollback { + return Err(DbError::InvalidData( + "metadata transaction must roll back".into(), + )); + } + if self.command_applied && self.published.is_none() && self.needs_snapshot().await? { + return Err(DbError::InvalidData( + "command has no authoritative metadata snapshot".into(), + )); + } + self.tx.commit().await?; + Ok(self.published) + } +} diff --git a/crates/buzz-db/src/store/channel_metadata/postgres_tests.rs b/crates/buzz-db/src/store/channel_metadata/postgres_tests.rs new file mode 100644 index 00000000000..32086158624 --- /dev/null +++ b/crates/buzz-db/src/store/channel_metadata/postgres_tests.rs @@ -0,0 +1,324 @@ +//! Exercise the actual admitted boundary against isolated PostgreSQL databases. + +mod activation_postgres_tests; +mod lifecycle_postgres_tests; + +use buzz_core::{ + channel::{ChannelType, ChannelVisibility}, + CommunityId, +}; +use nostr::{Event, EventBuilder, Keys, Kind, Tag}; +use uuid::Uuid; + +use super::{ChannelCreate, ChannelMetadataWrite, CommandApplication}; +use crate::{Db, DbConfig}; + +async fn database(migration: bool) -> (Db, CommunityId) { + let db = Db::new(&DbConfig { + database_url: crate::test_support::database_url(), + ..DbConfig::default() + }) + .await + .unwrap(); + if migration { + crate::migration::run_migrations(db.pool()).await.unwrap(); + } + let community = CommunityId::from_uuid(Uuid::new_v4()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community.as_uuid()) + .bind(format!("labels-{}.example", community.as_uuid())) + .execute(db.pool()) + .await + .unwrap(); + (db, community) +} + +fn command(keys: &Keys, channel: Uuid, kind: u16, operations: &[(&str, &str)]) -> Event { + let mut tags = vec![Tag::parse(["h", &channel.to_string()]).unwrap()]; + tags.extend( + operations + .iter() + .map(|(key, value)| Tag::parse([*key, *value]).unwrap()), + ); + EventBuilder::new(Kind::Custom(kind), "") + .tags(tags) + .sign_with_keys(keys) + .unwrap() +} + +fn fields() -> ChannelCreate { + ChannelCreate { + name: "labels".into(), + channel_type: ChannelType::Stream, + visibility: ChannelVisibility::Private, + description: Some("canonical description".into()), + ttl_seconds: None, + } +} + +async fn snapshot(write: &mut ChannelMetadataWrite, relay: &Keys) -> Event { + let tags = write.snapshot_tags().await.unwrap(); + let timestamp = write.snapshot_timestamp().unwrap(); + let relay = relay.clone(); + let event = tokio::task::spawn_blocking(move || { + EventBuilder::new(Kind::Custom(39000), "") + .tags(tags) + .allow_self_tagging() + .custom_created_at(timestamp) + .sign_with_keys(&relay) + .unwrap() + }) + .await + .unwrap(); + write.store_snapshot(&event, 512 * 1024).await.unwrap(); + event +} + +async fn apply(db: &Db, community: CommunityId, relay: &Keys, event: &Event) -> Option { + let channel = buzz_core::channel_labels::LabelCommand::parse(event) + .unwrap() + .unwrap() + .channel(); + let mut write = db + .begin_channel_metadata_write(community, channel, relay.public_key()) + .await + .unwrap(); + assert_eq!( + write.apply_command(event, Some(&fields())).await.unwrap(), + CommandApplication::Applied + ); + if write.needs_snapshot().await.unwrap() { + snapshot(&mut write, relay).await; + } + write.commit().await.unwrap().map(|stored| stored.event) +} + +async fn evidence(db: &Db, community: CommunityId, event: &Event) -> Option { + sqlx::query_scalar("SELECT nip_cl_applied FROM events WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .fetch_optional(db.pool()) + .await + .unwrap() +} + +async fn atomic_create_contract(migration: bool) { + let (db, community) = database(migration).await; + let owner = Keys::generate(); + let relay = Keys::generate(); + let channel = Uuid::new_v4(); + let create = command( + &owner, + channel, + 9007, + &[("name", "labels"), ("label", "team:infra")], + ); + let mut write = db + .begin_channel_metadata_write(community, channel, relay.public_key()) + .await + .unwrap(); + assert_eq!( + write.apply_command(&create, Some(&fields())).await.unwrap(), + CommandApplication::Applied + ); + // Missing snapshot must make commit impossible, with no torn channel/owner/receipt. + assert!(write.commit().await.is_err()); + assert!(db.get_channel(community, channel).await.is_err()); + assert_eq!(evidence(&db, community, &create).await, None); + let members: i64 = sqlx::query_scalar( + "SELECT count(*) FROM channel_members WHERE community_id=$1 AND channel_id=$2", + ) + .bind(community.as_uuid()) + .bind(channel) + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(members, 0); + + let published = apply(&db, community, &relay, &create).await.unwrap(); + assert_eq!(evidence(&db, community, &create).await, Some(true)); + assert!(db + .is_member(community, channel, owner.public_key().as_bytes()) + .await + .unwrap()); + assert_eq!( + buzz_core::channel_labels::verify_snapshot(&published, relay.public_key(), channel) + .unwrap() + .values(), + &["team:infra"] + ); + // A separate create must conflict, never recover somebody else's UUID as success. + let conflict = command(&owner, channel, 9007, &[("name", "other")]); + let mut write = db + .begin_channel_metadata_write(community, channel, relay.public_key()) + .await + .unwrap(); + assert_eq!( + write + .apply_command(&conflict, Some(&fields())) + .await + .unwrap(), + CommandApplication::Conflict + ); + write.rollback().await.unwrap(); + assert_eq!(evidence(&db, community, &conflict).await, None); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn create_is_atomic_on_desired_schema() { + atomic_create_contract(false).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn migration_schema_create_is_atomic() { + atomic_create_contract(true).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn noop_and_exact_retry_preserve_head_and_soft_deleted_evidence() { + let (db, community) = database(false).await; + let owner = Keys::generate(); + let relay = Keys::generate(); + let channel = Uuid::new_v4(); + let create = command(&owner, channel, 9007, &[("label", "a")]); + let original = apply(&db, community, &relay, &create).await.unwrap(); + let noop = command(&owner, channel, 9002, &[("add-label", "a")]); + assert!(apply(&db, community, &relay, &noop).await.is_none()); + assert_eq!(evidence(&db, community, &noop).await, Some(true)); + let write = db + .begin_channel_metadata_write(community, channel, relay.public_key()) + .await + .unwrap(); + assert_eq!(write.previous.as_ref().unwrap().event.id, original.id); + write.rollback().await.unwrap(); + let remove = command(&owner, channel, 9002, &[("remove-label", "a")]); + let removed = apply(&db, community, &relay, &remove).await.unwrap(); + assert!(removed.created_at > original.created_at); + db.soft_delete_event_and_update_thread(community, noop.id.as_bytes(), None, None) + .await + .unwrap(); + for event in [&noop, &create] { + let mut write = db + .begin_channel_metadata_write(community, channel, relay.public_key()) + .await + .unwrap(); + assert_eq!( + write.apply_command(event, Some(&fields())).await.unwrap(), + CommandApplication::Committed + ); + assert!(write.labels().is_empty()); + assert_eq!(write.previous.as_ref().unwrap().event.id, removed.id); + assert!(write.commit().await.unwrap().is_none()); + } + // Delayed ordinary publication captures the current empty set, not old tags. + db.set_topic( + community, + channel, + "fresh topic", + owner.public_key().as_bytes(), + ) + .await + .unwrap(); + let mut write = db + .begin_channel_metadata_write(community, channel, relay.public_key()) + .await + .unwrap(); + assert!(write.needs_snapshot().await.unwrap()); + let repaired = snapshot(&mut write, &relay).await; + assert!(repaired.created_at > removed.created_at); + assert!( + buzz_core::channel_labels::verify_snapshot(&repaired, relay.public_key(), channel) + .unwrap() + .is_empty() + ); + write.commit().await.unwrap(); + db.soft_delete_channel(community, channel).await.unwrap(); + let mut write = db + .begin_channel_metadata_write(community, channel, relay.public_key()) + .await + .unwrap(); + assert!(write.needs_snapshot().await.is_err()); + assert_eq!( + write.apply_command(&noop, None).await.unwrap(), + CommandApplication::Restricted + ); + write.rollback().await.unwrap(); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn concurrent_adds_preserve_values_and_retry_authority_is_fresh() { + let (db, community) = database(false).await; + let owner = Keys::generate(); + let relay = Keys::generate(); + let channel = Uuid::new_v4(); + let create = command(&owner, channel, 9007, &[]); + apply(&db, community, &relay, &create).await; + let first = command(&owner, channel, 9002, &[("add-label", "a")]); + let second = command(&owner, channel, 9002, &[("add-label", "b")]); + let (a, b) = tokio::join!( + apply(&db, community, &relay, &first), + apply(&db, community, &relay, &second) + ); + assert!(a.is_some() && b.is_some()); + let write = db + .begin_channel_metadata_write(community, channel, relay.public_key()) + .await + .unwrap(); + assert_eq!(write.labels().values(), &["a", "b"]); + write.rollback().await.unwrap(); + sqlx::query("UPDATE channel_members SET role='member' WHERE community_id=$1 AND channel_id=$2") + .bind(community.as_uuid()) + .bind(channel) + .execute(db.pool()) + .await + .unwrap(); + let mut write = db + .begin_channel_metadata_write(community, channel, relay.public_key()) + .await + .unwrap(); + assert_eq!( + write.apply_command(&first, None).await.unwrap(), + CommandApplication::Restricted + ); + write.rollback().await.unwrap(); + assert_eq!(evidence(&db, community, &first).await, Some(true)); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn legacy_evidence_is_unknown_and_scoped_to_community() { + let (db, a) = database(false).await; + let owner = Keys::generate(); + let relay = Keys::generate(); + let channel = Uuid::new_v4(); + let create = command(&owner, channel, 9007, &[]); + apply(&db, a, &relay, &create).await; + let legacy = command(&owner, channel, 9002, &[("add-label", "legacy")]); + db.insert_event(a, &legacy, Some(channel)).await.unwrap(); + let mut write = db + .begin_channel_metadata_write(a, channel, relay.public_key()) + .await + .unwrap(); + assert_eq!( + write.apply_command(&legacy, None).await.unwrap(), + CommandApplication::Unknown + ); + assert!(write.labels().is_empty()); + write.rollback().await.unwrap(); + let b = CommunityId::from_uuid(Uuid::new_v4()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(b.as_uuid()) + .bind(format!("labels-b-{}.example", b.as_uuid())) + .execute(db.pool()) + .await + .unwrap(); + // Identical UUID and signed event in B creates independent state, not an A receipt. + assert!(apply(&db, b, &relay, &create).await.is_some()); + assert!(apply(&db, b, &relay, &legacy).await.is_some()); + assert_eq!(evidence(&db, a, &legacy).await, Some(false)); + assert_eq!(evidence(&db, b, &legacy).await, Some(true)); +} diff --git a/crates/buzz-db/src/store/channel_metadata/postgres_tests/activation_postgres_tests.rs b/crates/buzz-db/src/store/channel_metadata/postgres_tests/activation_postgres_tests.rs new file mode 100644 index 00000000000..1d5ffa9cbb1 --- /dev/null +++ b/crates/buzz-db/src/store/channel_metadata/postgres_tests/activation_postgres_tests.rs @@ -0,0 +1,149 @@ +use super::*; + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn activation_checks_current_content_and_all_communities_without_mutation() { + let (db, community) = database(false).await; + let relay = Keys::generate(); + let owner = Keys::generate(); + // Page boundary: the final bad channel must not hide behind 128 good rows. + for index in 1..=129u128 { + let channel = Uuid::from_u128(index); + let create = command(&owner, channel, 9007, &[("label", "retained")]); + apply(&db, community, &relay, &create).await; + } + assert_eq!( + db.verify_channel_metadata_activation(relay.public_key()) + .await + .unwrap(), + 129 + ); + let channel = Uuid::from_u128(129); + db.set_topic( + community, + channel, + "updated without publication", + owner.public_key().as_bytes(), + ) + .await + .unwrap(); + assert!(db + .verify_channel_metadata_activation(relay.public_key()) + .await + .is_err()); + let mut write = db + .begin_channel_metadata_write(community, channel, relay.public_key()) + .await + .unwrap(); + snapshot(&mut write, &relay).await; + write.commit().await.unwrap(); + assert_eq!( + db.verify_channel_metadata_activation(relay.public_key()) + .await + .unwrap(), + 129 + ); + // A correctly signed but stale-label head is still incompatible. + sqlx::query("UPDATE channels SET labels = ARRAY[]::text[] WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()) + .bind(channel) + .execute(db.pool()) + .await + .unwrap(); + assert!(db + .verify_channel_metadata_activation(relay.public_key()) + .await + .is_err()); + let mut write = db + .begin_channel_metadata_write(community, channel, relay.public_key()) + .await + .unwrap(); + snapshot(&mut write, &relay).await; + write.commit().await.unwrap(); + assert_eq!( + db.verify_channel_metadata_activation(relay.public_key()) + .await + .unwrap(), + 129 + ); + // A second tenant is checked independently; the audit does not create its head. + let other = db + .ensure_configured_community("activation-other.example") + .await + .unwrap() + .id; + db.create_channel_with_id( + other, + Uuid::new_v4(), + "missing", + ChannelType::Stream, + ChannelVisibility::Open, + None, + owner.public_key().as_bytes(), + None, + ) + .await + .unwrap(); + assert!(db + .verify_channel_metadata_activation(relay.public_key()) + .await + .is_err()); + let count: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id = $1 AND kind = 39000") + .bind(other.as_uuid()) + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!( + count, 0, + "activation must not silently repair incompatible state" + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn repair_pages_cover_the_catalog_without_crossing_tenants_or_tombstones() { + let (db, community) = database(false).await; + let other = db + .ensure_configured_community("repair-other.example") + .await + .unwrap() + .id; + // Fixtures only: more than the interactive catalog cap, including one + // archived row that repair must retain and one deleted row it must omit. + sqlx::query( + "INSERT INTO channels (community_id, id, name, created_by, archived_at, deleted_at) \ + SELECT $1, lpad(to_hex(n), 32, '0')::uuid, 'repair', decode(repeat('11', 32), 'hex'), \ + CASE WHEN n = 1001 THEN now() END, CASE WHEN n = 1002 THEN now() END \ + FROM generate_series(1, 1002) AS n", + ) + .bind(community.as_uuid()) + .execute(db.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO channels (community_id, id, name, created_by) \ + VALUES ($1, $2, 'other', decode(repeat('11', 32), 'hex'))", + ) + .bind(other.as_uuid()) + .bind(Uuid::from_u128(1003)) + .execute(db.pool()) + .await + .unwrap(); + let mut cursor = Uuid::nil(); + let mut all = Vec::new(); + loop { + let page = db + .channel_metadata_repair_page(community, cursor) + .await + .unwrap(); + assert!(page.len() <= 128); + if page.is_empty() { + break; + } + assert!(page.iter().all(|id| *id > cursor)); + cursor = *page.last().unwrap(); + all.extend(page); + } + assert_eq!(all, (1..=1001).map(Uuid::from_u128).collect::>()); +} diff --git a/crates/buzz-db/src/store/channel_metadata/postgres_tests/lifecycle_postgres_tests.rs b/crates/buzz-db/src/store/channel_metadata/postgres_tests/lifecycle_postgres_tests.rs new file mode 100644 index 00000000000..dcd98d9be03 --- /dev/null +++ b/crates/buzz-db/src/store/channel_metadata/postgres_tests/lifecycle_postgres_tests.rs @@ -0,0 +1,274 @@ +//! Force both serialization orders through supported lifecycle writers. Lock +//! observations, not sleeps or task start order, establish the schedule. + +use std::time::Duration; + +use buzz_core::channel::MemberRole; + +use super::*; + +#[derive(Clone, Copy, Debug)] +enum Transition { + RemoveAdmin, + Archive, + Delete, +} + +impl Transition { + async fn apply( + self, + db: &Db, + community: CommunityId, + channel: Uuid, + admin: &Keys, + relay: &Keys, + ) { + match self { + Self::RemoveAdmin => db + .remove_member( + community, + channel, + admin.public_key().as_bytes(), + admin.public_key().as_bytes(), + ) + .await + .unwrap(), + Self::Archive => db.archive_channel(community, channel).await.unwrap(), + Self::Delete => { + assert!(db.soft_delete_channel(community, channel).await.unwrap()); + db.soft_delete_discovery_events(community, channel, relay.public_key().as_bytes()) + .await + .unwrap(); + } + } + } +} + +async fn admin(db: &Db, community: CommunityId, channel: Uuid, owner: &Keys) -> Keys { + let admin = Keys::generate(); + db.add_member( + community, + channel, + admin.public_key().as_bytes(), + MemberRole::Admin, + Some(owner.public_key().as_bytes()), + ) + .await + .unwrap(); + admin +} + +async fn blocked_pid(db: &Db, blocker: i32) -> i32 { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let pid: Option = sqlx::query_scalar( + "SELECT pid FROM pg_stat_activity WHERE datname = current_database() \ + AND $1 = ANY(pg_blocking_pids(pid)) ORDER BY pid LIMIT 1", + ) + .bind(blocker) + .fetch_optional(db.pool()) + .await + .unwrap(); + if let Some(pid) = pid { + return pid; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("supported writer must actually wait behind the selected lock") +} + +async fn assert_rejected_without_effects( + db: &Db, + community: CommunityId, + channel: Uuid, + relay: &Keys, + event: &Event, + expected_evidence: Option, +) { + let mut write = db + .begin_channel_metadata_write(community, channel, relay.public_key()) + .await + .unwrap(); + let labels = write.labels().clone(); + let head = write.previous.as_ref().map(|stored| stored.event.id); + assert_eq!( + write.apply_command(event, Some(&fields())).await.unwrap(), + CommandApplication::Restricted + ); + assert_eq!(write.labels(), &labels); + assert_eq!(write.previous.as_ref().map(|stored| stored.event.id), head); + assert!(write.commit().await.unwrap().is_none()); + assert_eq!(evidence(db, community, event).await, expected_evidence); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn metadata_commit_precedes_waiting_removal_archive_and_deletion() { + let (db, community) = database(false).await; + let owner = Keys::generate(); + let relay = Keys::generate(); + for transition in [ + Transition::RemoveAdmin, + Transition::Archive, + Transition::Delete, + ] { + let channel = Uuid::new_v4(); + let create = command(&owner, channel, 9007, &[("label", "original")]); + apply(&db, community, &relay, &create).await; + let admin = admin(&db, community, channel, &owner).await; + let event = command(&admin, channel, 9002, &[("add-label", "committed")]); + let mut write = db + .begin_channel_metadata_write(community, channel, relay.public_key()) + .await + .unwrap(); + let pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(write.tx.conn()) + .await + .unwrap(); + let finish = async { + blocked_pid(&db, pid).await; + assert_eq!( + write.apply_command(&event, None).await.unwrap(), + CommandApplication::Applied + ); + snapshot(&mut write, &relay).await; + assert!(write.commit().await.unwrap().is_some()); + }; + tokio::join!( + transition.apply(&db, community, channel, &admin, &relay), + finish + ); + assert_eq!(evidence(&db, community, &event).await, Some(true)); + assert_rejected_without_effects(&db, community, channel, &relay, &event, Some(true)).await; + let new = command(&admin, channel, 9002, &[("add-label", "denied")]); + assert_rejected_without_effects(&db, community, channel, &relay, &new, None).await; + let mut write = db + .begin_channel_metadata_write(community, channel, relay.public_key()) + .await + .unwrap(); + assert_eq!(write.labels().values(), &["committed", "original"]); + if matches!(transition, Transition::Delete) { + assert!(write.previous.is_none()); + assert!(write.needs_snapshot().await.is_err()); + } + write.rollback().await.unwrap(); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn metadata_queued_after_removal_archive_and_deletion_rechecks_state() { + let (db, community) = database(false).await; + let owner = Keys::generate(); + let relay = Keys::generate(); + for transition in [ + Transition::RemoveAdmin, + Transition::Archive, + Transition::Delete, + ] { + let channel = Uuid::new_v4(); + let create = command(&owner, channel, 9007, &[("label", "original")]); + apply(&db, community, &relay, &create).await; + let admin = admin(&db, community, channel, &owner).await; + let prior = command(&admin, channel, 9002, &[("add-label", "original")]); + apply(&db, community, &relay, &prior).await; + let event = command(&admin, channel, 9002, &[("add-label", "denied")]); + + // Test-only latch stops the real writer after it acquired its authority + // lock (removal) or queued for the channel row (archive/deletion). + let mut latch = db.pool().begin().await.unwrap(); + let pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *latch) + .await + .unwrap(); + match transition { + Transition::RemoveAdmin => { + sqlx::query("SELECT 1 FROM channel_members WHERE community_id=$1 AND channel_id=$2 AND pubkey=$3 FOR UPDATE") + .bind(community.as_uuid()).bind(channel).bind(admin.public_key().as_bytes().as_slice()) + .execute(&mut *latch).await.unwrap(); + } + Transition::Archive | Transition::Delete => { + sqlx::query( + "SELECT 1 FROM channels WHERE community_id=$1 AND id=$2 FOR NO KEY UPDATE", + ) + .bind(community.as_uuid()) + .bind(channel) + .execute(&mut *latch) + .await + .unwrap(); + } + } + let schedule = async { + let writer_pid = blocked_pid(&db, pid).await; + let release = async { + blocked_pid(&db, writer_pid).await; + latch.commit().await.unwrap(); + }; + let (result, ()) = tokio::join!( + db.begin_channel_metadata_write(community, channel, relay.public_key()), + release + ); + let mut write = result.unwrap(); + assert_eq!( + write.apply_command(&event, None).await.unwrap(), + CommandApplication::Restricted + ); + assert_eq!(write.labels().values(), &["original"]); + assert!(write.commit().await.unwrap().is_none()); + }; + tokio::join!( + transition.apply(&db, community, channel, &admin, &relay), + schedule + ); + assert_eq!(evidence(&db, community, &event).await, None); + assert_rejected_without_effects(&db, community, channel, &relay, &prior, Some(true)).await; + if !matches!(transition, Transition::RemoveAdmin) { + assert_rejected_without_effects(&db, community, channel, &relay, &create, Some(true)) + .await; + } + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn failed_snapshot_rolls_back_mutation_and_evidence_then_exact_retry_applies_once() { + let (db, community) = database(false).await; + let owner = Keys::generate(); + let relay = Keys::generate(); + let channel = Uuid::new_v4(); + let create = command(&owner, channel, 9007, &[("label", "original")]); + let original = apply(&db, community, &relay, &create).await.unwrap(); + let event = command(&owner, channel, 9002, &[("add-label", "new")]); + let mut write = db + .begin_channel_metadata_write(community, channel, relay.public_key()) + .await + .unwrap(); + assert_eq!( + write.apply_command(&event, None).await.unwrap(), + CommandApplication::Applied + ); + // A stale signed projection cannot authorize a partial commit. + assert!(write.store_snapshot(&original, 512 * 1024).await.is_err()); + assert!(write.commit().await.is_err()); + assert_eq!(evidence(&db, community, &event).await, None); + let write = db + .begin_channel_metadata_write(community, channel, relay.public_key()) + .await + .unwrap(); + assert_eq!(write.labels().values(), &["original"]); + assert_eq!(write.previous.as_ref().unwrap().event.id, original.id); + write.rollback().await.unwrap(); + let applied = apply(&db, community, &relay, &event).await.unwrap(); + let mut write = db + .begin_channel_metadata_write(community, channel, relay.public_key()) + .await + .unwrap(); + assert_eq!( + write.apply_command(&event, None).await.unwrap(), + CommandApplication::Committed + ); + assert_eq!(write.previous.as_ref().unwrap().event.id, applied.id); + assert!(write.commit().await.unwrap().is_none()); +} diff --git a/crates/buzz-db/src/store/channel_metadata/snapshot.rs b/crates/buzz-db/src/store/channel_metadata/snapshot.rs new file mode 100644 index 00000000000..63045d807c6 --- /dev/null +++ b/crates/buzz-db/src/store/channel_metadata/snapshot.rs @@ -0,0 +1,178 @@ +use buzz_core::channel_labels::{verify_snapshot, LabelSet}; +use nostr::{Event, Tag}; + +use super::ChannelMetadataWrite; +use crate::{DbError, Result}; + +impl ChannelMetadataWrite { + /// Build complete metadata from locked current state, never captured caller tags. + pub async fn snapshot_tags(&mut self) -> Result> { + let channel = self.channel()?; + let mut parts = vec![ + vec!["d".into(), self.channel_id.to_string()], + vec!["name".into(), channel.name.clone()], + vec!["closed".into()], + vec!["t".into(), channel.channel_type.clone()], + vec![if channel.visibility == "private" { + "private" + } else { + "public" + } + .into()], + ]; + for (key, value) in [ + ("about", &channel.description), + ("topic", &channel.topic), + ("purpose", &channel.purpose), + ] { + if let Some(value) = value.as_ref().filter(|value| !value.is_empty()) { + parts.push(vec![key.into(), value.clone()]); + } + } + if channel.archived_at.is_some() { + parts.push(vec!["archived".into(), "true".into()]); + } + if let Some(ttl) = channel.ttl_seconds { + parts.push(vec!["ttl".into(), ttl.to_string()]); + } + if let Some(deadline) = channel.ttl_deadline { + parts.push(vec!["ttl_deadline".into(), deadline.to_rfc3339()]); + } + if channel.channel_type == "dm" { + parts.push(vec!["hidden".into()]); + let members: Vec> = sqlx::query_scalar( + "SELECT pubkey FROM channel_members WHERE community_id = $1 AND channel_id = $2 \ + AND removed_at IS NULL ORDER BY pubkey", + ) + .bind(self.tx.community().as_uuid()) + .bind(self.channel_id) + .fetch_all(self.tx.conn()) + .await?; + parts.extend( + members + .into_iter() + .map(|key| vec!["p".into(), hex::encode(key)]), + ); + } + parts.extend(self.labels.snapshot_tags()); + parts + .into_iter() + .map(|parts| Tag::parse(parts).map_err(|e| DbError::InvalidData(e.to_string()))) + .collect() + } + + /// Timestamp for a new snapshot, strictly newer than the prior live head. + pub fn snapshot_timestamp(&self) -> Result { + let now = nostr::Timestamp::now().as_secs(); + let next = self + .previous + .as_ref() + .map(|e| e.event.created_at.as_secs().checked_add(1)) + .unwrap_or(Some(now)) + .ok_or_else(|| DbError::InvalidData("metadata timestamp overflow".into()))?; + Ok(nostr::Timestamp::from(next.max(now))) + } + + /// Compare canonical content, not existence. A no-op reuses a sound snapshot. + pub async fn needs_snapshot(&mut self) -> Result { + let tags = self.snapshot_tags().await?; + let Some(previous) = self.previous.as_ref() else { + return Ok(true); + }; + if verify_snapshot_async(&previous.event, self.relay, self.channel_id) + .await? + .ok() + .as_ref() + != Some(&self.labels) + { + return Ok(true); + } + // The existing TTL protocol refreshes deadlines on event commits. It is + // not a reason to publish a fresh snapshot for a label no-op. Compare all + // other fields without imposing a new order on ordinary metadata tags. + let comparable = |tags: Vec| { + let mut parts: Vec<_> = tags + .into_iter() + .map(|t| t.as_slice().to_vec()) + .filter(|p| p[0] != "ttl_deadline") + .collect(); + parts.sort(); + parts + }; + Ok(!previous.event.content.is_empty() + || comparable(tags) != comparable(previous.event.tags.iter().cloned().collect())) + } + + /// Store a freshly signed snapshot on this transaction after validating its projection. + /// + /// The caller signs `snapshot_tags()` with `snapshot_timestamp()`. No network + /// work or pool checkout belongs between capture and this method. + pub async fn store_snapshot(&mut self, event: &Event, max_bytes: usize) -> Result<()> { + if self.published.is_some() || self.must_rollback { + return Err(DbError::InvalidData( + "one snapshot per metadata transaction".into(), + )); + } + let expected = self.snapshot_tags().await?; + let labels = verify_snapshot_async(event, self.relay, self.channel_id) + .await? + .map_err(|e| DbError::InvalidData(e.to_string()))?; + if labels != self.labels + || !event.content.is_empty() + || event.tags.iter().ne(expected.iter()) + || self + .previous + .as_ref() + .is_some_and(|old| event.created_at <= old.event.created_at) + || serde_json::to_vec(event)?.len() > max_bytes + { + return Err(DbError::InvalidData( + "snapshot does not match locked metadata or limits".into(), + )); + } + self.must_rollback = true; + retire_previous_snapshots(&mut self.tx, self.relay, self.channel_id).await?; + let (stored, inserted) = + crate::event::insert_event_in_transaction(&mut self.tx, event, Some(self.channel_id)) + .await?; + if !inserted { + return Err(DbError::InvalidData( + "metadata snapshot already exists".into(), + )); + } + self.published = Some(stored); + self.must_rollback = false; + Ok(()) + } +} + +// Cryptography cannot occupy the async runtime while a transaction waits on it. +async fn verify_snapshot_async( + event: &Event, + relay: nostr::PublicKey, + channel: uuid::Uuid, +) -> Result> { + let event = event.clone(); + tokio::task::spawn_blocking(move || verify_snapshot(&event, relay, channel)) + .await + .map_err(|error| { + DbError::InvalidData(format!("snapshot verification task failed: {error}")) + }) +} + +async fn retire_previous_snapshots( + tx: &mut crate::AdmittedTx, + relay: nostr::PublicKey, + channel: uuid::Uuid, +) -> Result<()> { + sqlx::query( + "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND kind = 39000 \ + AND pubkey = $2 AND channel_id = $3 AND deleted_at IS NULL", + ) + .bind(tx.community().as_uuid()) + .bind(relay.as_bytes().as_slice()) + .bind(channel) + .execute(tx.conn()) + .await?; + Ok(()) +} diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index 183585e2785..dc9684510ab 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -19,6 +19,11 @@ use buzz_datastore_tracing::datastore_span; use crate::error::{DbError, Result}; use crate::{AdmittedTx, Db}; +mod metadata_filter; +mod tag_filter; + +pub use metadata_filter::ChannelMetadataRead; + // Compatibility exports preserve the pre-extraction public event-store paths. pub use crate::reminder::{ claim_due_reminder, claim_due_reminder_with_stamp, query_due_reminders, release_due_reminder, @@ -92,6 +97,13 @@ pub struct EventQuery { /// Restrict results to events with an exact custom tag pair. /// Uses JSONB containment against `tags` before SQL `LIMIT`. pub custom_tag: Option<(String, String)>, + /// Exact NIP-01 tag predicates, applied before LIMIT and by COUNT. + /// Values within a key use OR; keys use AND. Only tag positions 0 and 1 + /// participate, unlike JSONB containment alone (which ignores array order). + pub tag_filters: Vec<(String, Vec)>, + /// Current relay metadata and read ACL, checked in SQL before filtering/limits. + /// Queries with this authorization predicate must use the writer pool. + pub channel_metadata_read: Option, /// Restrict results to events in any of these channels. By default, /// channel-less global events are retained so this can enforce a viewer's /// accessible-channel scope without hiding global events. Set @@ -152,6 +164,8 @@ impl EventQuery { e_tags: None, d_tag_values: None, custom_tag: None, + tag_filters: Vec::new(), + channel_metadata_read: None, channel_ids: None, channel_ids_include_global: true, max_limit: None, @@ -735,6 +749,9 @@ fn build_query_events_sql(q: &EventQuery) -> QueryBuilder { push_artifact_d_tag_predicate(&mut qb, col_prefix, values); } + metadata_filter::push_predicates(&mut qb, col_prefix, q.channel_metadata_read.as_ref()); + tag_filter::push_predicates(&mut qb, col_prefix, &q.tag_filters); + if let Some((ref name, ref value)) = q.custom_tag { let containment = serde_json::json!([[name, value]]); qb.push(format!(" AND {col_prefix}tags @> ")) @@ -1063,6 +1080,9 @@ pub(crate) async fn count_events_on(conn: &mut sqlx::PgConnection, q: &EventQuer push_artifact_d_tag_predicate(&mut qb, col_prefix, values); } + metadata_filter::push_predicates(&mut qb, col_prefix, q.channel_metadata_read.as_ref()); + tag_filter::push_predicates(&mut qb, col_prefix, &q.tag_filters); + if let Some(s) = q.since { qb.push(format!(" AND {col_prefix}created_at >= ")) .push_bind(s); @@ -2202,6 +2222,10 @@ impl Db { path: &'static str, q: &EventQuery, ) -> Result> { + if q.channel_metadata_read.is_some() { + Self::record_route(path, "writer", "metadata_authorization"); + return self.query_events(q).await; + } let predicate = crate::RoutePredicate::for_query(q, self.replica_read_max_age.is_some()); match self .route_read( @@ -2262,6 +2286,10 @@ impl Db { path: &'static str, q: &EventQuery, ) -> Result> { + if q.channel_metadata_read.is_some() { + Self::record_route(path, "writer", "metadata_authorization"); + return self.query_events(q).await; + } match self .route_read( path, @@ -2326,6 +2354,10 @@ impl Db { /// the error to the accepted budget `B`. #[datastore_span(name = "count_events_routed", system = "postgresql")] pub async fn count_events_routed(&self, path: &'static str, q: &EventQuery) -> Result { + if q.channel_metadata_read.is_some() { + Self::record_route(path, "writer", "metadata_authorization"); + return self.count_events(q).await; + } match self .route_read( path, diff --git a/crates/buzz-db/src/store/event/metadata_filter.rs b/crates/buzz-db/src/store/event/metadata_filter.rs new file mode 100644 index 00000000000..4826b45ef6b --- /dev/null +++ b/crates/buzz-db/src/store/event/metadata_filter.rs @@ -0,0 +1,41 @@ +//! Current relay metadata and its ACL share the query's MVCC snapshot. + +use sqlx::{Postgres, QueryBuilder}; + +/// Trusted request context for channel metadata reads (never event-supplied). +#[derive(Debug, Clone)] +pub struct ChannelMetadataRead { + /// Authenticated reader after community and token admission. + pub reader: Vec, + /// Configured relay signer, not a client-supplied authors filter. + pub relay: Vec, +} + +pub(super) fn push_predicates( + qb: &mut QueryBuilder, + col_prefix: &str, + read: Option<&ChannelMetadataRead>, +) { + let Some(read) = read else { return }; + let table = if col_prefix.is_empty() { "events" } else { "e" }; + qb.push(format!(" AND ({table}.kind <> 39000 OR ({table}.pubkey = ")) + .push_bind(read.relay.clone()) + .push(format!( + " AND EXISTS (SELECT 1 FROM channels mc WHERE mc.community_id = {table}.community_id \ + AND mc.id = {table}.channel_id AND mc.deleted_at IS NULL \ + AND (mc.visibility = 'open' OR EXISTS (SELECT 1 FROM channel_members mm \ + WHERE mm.community_id = mc.community_id AND mm.channel_id = mc.id \ + AND mm.removed_at IS NULL AND mm.pubkey = " + )) + .push_bind(read.reader.clone()) + .push(format!( + "))) AND NOT EXISTS (SELECT 1 FROM events newer \ + WHERE newer.community_id = {table}.community_id AND newer.kind = 39000 \ + AND newer.pubkey = {table}.pubkey AND newer.channel_id = {table}.channel_id \ + AND newer.deleted_at IS NULL AND (newer.created_at > {table}.created_at \ + OR (newer.created_at = {table}.created_at AND newer.id < {table}.id)))))" + )); +} + +#[cfg(test)] +mod postgres_tests; diff --git a/crates/buzz-db/src/store/event/metadata_filter/postgres_tests.rs b/crates/buzz-db/src/store/event/metadata_filter/postgres_tests.rs new file mode 100644 index 00000000000..c47c64d4151 --- /dev/null +++ b/crates/buzz-db/src/store/event/metadata_filter/postgres_tests.rs @@ -0,0 +1,149 @@ +use super::ChannelMetadataRead; +use crate::event::{count_events, insert_event, query_events, EventQuery}; +use buzz_core::CommunityId; +use nostr::{Event, EventBuilder, Keys, Kind, Tag, Timestamp}; +use sqlx::PgPool; +use uuid::Uuid; + +async fn community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("metadata-{id}.example")) + .execute(pool) + .await + .unwrap(); + CommunityId::from_uuid(id) +} + +async fn channel(pool: &PgPool, community: CommunityId, id: Uuid, private: bool) { + sqlx::query("INSERT INTO channels (id, community_id, name, visibility, created_by) VALUES ($1, $2, 'metadata', $3::channel_visibility, $4)") + .bind(id).bind(community.as_uuid()).bind(if private { "private" } else { "open" }) + .bind(vec![7_u8; 32]).execute(pool).await.unwrap(); +} + +fn event(keys: &Keys, channel: Uuid, time: u64, label: &str) -> Event { + EventBuilder::new(Kind::Custom(39000), "") + .tags([ + Tag::parse(["d", &channel.to_string()]).unwrap(), + Tag::parse(["t", "stream"]).unwrap(), + Tag::parse(["L", "nip-cl"]).unwrap(), + Tag::parse(["l", label, "nip-cl"]).unwrap(), + ]) + .custom_created_at(Timestamp::from(time)) + .sign_with_keys(keys) + .unwrap() +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn current_metadata_acl_signer_and_head_precede_filters_and_limit() { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let a = community(&pool).await; + let b = community(&pool).await; + let relay = Keys::generate(); + let outsider = Keys::generate(); + let reader = Keys::generate(); + let visible = Uuid::new_v4(); + let superseded = Uuid::new_v4(); + let hidden = Uuid::new_v4(); + let deleted = Uuid::new_v4(); + let forged = Uuid::new_v4(); + for id in [visible, superseded, hidden, deleted, forged] { + channel(&pool, a, id, id == hidden).await; + } + channel(&pool, b, visible, false).await; + let now = Timestamp::now().as_secs(); + let oldest_match = event(&relay, visible, now - 100, "a"); + for (scope, candidate, id) in [ + (a, oldest_match.clone(), visible), + (b, event(&relay, visible, now + 10, "a"), visible), + (a, event(&relay, superseded, now - 50, "a"), superseded), + (a, event(&relay, superseded, now - 40, "b"), superseded), + (a, event(&relay, hidden, now - 30, "a"), hidden), + (a, event(&relay, deleted, now - 20, "a"), deleted), + (a, event(&outsider, forged, now - 10, "a"), forged), + ] { + insert_event(&pool, scope, &candidate, Some(id)) + .await + .unwrap(); + } + sqlx::query("UPDATE channels SET deleted_at = NOW() WHERE community_id = $1 AND id = $2") + .bind(a.as_uuid()) + .bind(deleted) + .execute(&pool) + .await + .unwrap(); + let mut query = EventQuery::for_community(a); + query.kinds = Some(vec![39000]); + query.limit = Some(1); + // Deliberately stale catalog: the SQL ACL must reject hidden/deleted rows. + query.channel_ids = Some(vec![visible, superseded, hidden, deleted, forged]); + query.channel_metadata_read = Some(ChannelMetadataRead { + reader: reader.public_key().to_bytes().to_vec(), + relay: relay.public_key().to_bytes().to_vec(), + }); + query.tag_filters = vec![ + ("L".into(), vec!["nip-cl".into()]), + ("l".into(), vec!["a".into()]), + ]; + let rows = query_events(&pool, &query).await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].event.id, oldest_match.id); + assert_eq!(count_events(&pool, &query).await.unwrap(), 1); + + // An until bound or explicit event ID cannot resurrect an old matching head. + query.channel_id = Some(superseded); + query.until = Some(chrono::DateTime::from_timestamp((now - 45) as i64, 0).unwrap()); + assert!(query_events(&pool, &query).await.unwrap().is_empty()); + query.until = None; + query.channel_id = None; + + sqlx::query("INSERT INTO channel_members (community_id, channel_id, pubkey, role) VALUES ($1, $2, $3, 'member')") + .bind(a.as_uuid()).bind(hidden).bind(reader.public_key().as_bytes().as_slice()).execute(&pool).await.unwrap(); + assert_eq!( + query_events(&pool, &query).await.unwrap()[0].channel_id, + Some(hidden) + ); + assert_eq!(count_events(&pool, &query).await.unwrap(), 2); + sqlx::query( + "UPDATE channel_members SET removed_at = NOW() WHERE community_id = $1 AND channel_id = $2", + ) + .bind(a.as_uuid()) + .bind(hidden) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + query_events(&pool, &query).await.unwrap()[0].event.id, + oldest_match.id + ); + assert_eq!(count_events(&pool, &query).await.unwrap(), 1); + + // Archive preserves readability; a tied replacement selects the lowest ID. + sqlx::query("UPDATE channels SET archived_at = NOW() WHERE community_id = $1 AND id = $2") + .bind(a.as_uuid()) + .bind(visible) + .execute(&pool) + .await + .unwrap(); + let tied = event(&relay, visible, now - 100, "b"); + insert_event(&pool, a, &tied, Some(visible)).await.unwrap(); + let (winner, loser) = if tied.id < oldest_match.id { + (&tied, &oldest_match) + } else { + (&oldest_match, &tied) + }; + query.tag_filters.clear(); + query.channel_id = Some(visible); + assert_eq!( + query_events(&pool, &query).await.unwrap()[0].event.id, + winner.id + ); + assert_eq!(count_events(&pool, &query).await.unwrap(), 1); + query.ids = Some(vec![loser.id.as_bytes().to_vec()]); + assert!(query_events(&pool, &query).await.unwrap().is_empty()); + assert_eq!(count_events(&pool, &query).await.unwrap(), 0); +} diff --git a/crates/buzz-db/src/store/event/tag_filter.rs b/crates/buzz-db/src/store/event/tag_filter.rs new file mode 100644 index 00000000000..a73a680c0fa --- /dev/null +++ b/crates/buzz-db/src/store/event/tag_filter.rs @@ -0,0 +1,35 @@ +//! NIP-01 first-value matching shared by historical queries and exact COUNT. + +use sqlx::{Postgres, QueryBuilder}; + +pub(super) fn push_predicates( + qb: &mut QueryBuilder, + col_prefix: &str, + filters: &[(String, Vec)], +) { + for (name, values) in filters { + if values.is_empty() { + qb.push(" AND FALSE"); + continue; + } + // Use the existing tags GIN index to narrow candidates, then check + // positions. Containment alone also matches a value in a namespace or + // trailing marker, and even a reversed [value, name] array. + let containments: Vec = values + .iter() + .map(|value| serde_json::json!([[name, value]])) + .collect(); + qb.push(format!(" AND {col_prefix}tags @> ANY(")) + .push_bind(containments) + .push("::jsonb[]) AND EXISTS (SELECT 1 FROM jsonb_array_elements(") + .push(format!("{col_prefix}tags")) + .push(") AS matched_tag WHERE matched_tag->>0 = ") + .push_bind(name.clone()) + .push(" AND matched_tag->>1 = ANY(") + .push_bind(values.clone()) + .push("::text[]))"); + } +} + +#[cfg(test)] +mod postgres_tests; diff --git a/crates/buzz-db/src/store/event/tag_filter/postgres_tests.rs b/crates/buzz-db/src/store/event/tag_filter/postgres_tests.rs new file mode 100644 index 00000000000..dd76165c30f --- /dev/null +++ b/crates/buzz-db/src/store/event/tag_filter/postgres_tests.rs @@ -0,0 +1,129 @@ +//! Real SQL binds and NIP-01 matching, including adversarial tag positions. + +use buzz_core::{filter::filters_match, CommunityId}; +use nostr::{Event, EventBuilder, Filter, Keys, Kind, Tag, Timestamp}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::event::{count_events, insert_event, query_events, EventQuery}; + +async fn community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("tag-filter-{id}.example")) + .execute(pool) + .await + .unwrap(); + CommunityId::from_uuid(id) +} + +async fn channel(pool: &PgPool, community: CommunityId) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO channels (id, community_id, name, created_by) VALUES ($1, $2, 'tags', $3)", + ) + .bind(id) + .bind(community.as_uuid()) + .bind(vec![7_u8; 32]) + .execute(pool) + .await + .unwrap(); + id +} + +fn event(keys: &Keys, channel: Uuid, time: u64, tags: &[&[&str]]) -> Event { + let mut parsed = vec![Tag::parse(["d", &channel.to_string()]).unwrap()]; + parsed.extend( + tags.iter() + .map(|parts| Tag::parse(parts.iter().copied()).unwrap()), + ); + EventBuilder::new(Kind::Custom(39000), "") + .tags(parsed) + .custom_created_at(Timestamp::from(time)) + .sign_with_keys(keys) + .unwrap() +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn exact_tag_predicates_precede_limit_and_match_count_with_tenant_and_acl_scope() { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let a = community(&pool).await; + let b = community(&pool).await; + let visible = channel(&pool, a).await; + let hidden = channel(&pool, a).await; + let foreign = channel(&pool, b).await; + let keys = Keys::generate(); + let now = Timestamp::now().as_secs(); + let matching_tags: &[&[&str]] = &[&["L", "nip-cl"], &["l", "a", "nip-cl"], &["t", "stream"]]; + let older = event(&keys, visible, now - 100, matching_tags); + insert_event(&pool, a, &older, Some(visible)).await.unwrap(); + for (scope, ch) in [(a, hidden), (b, foreign)] { + let event = event(&keys, ch, now, matching_tags); + insert_event(&pool, scope, &event, Some(ch)).await.unwrap(); + } + // Every newer row must be eliminated in SQL, not after the one-row limit. + for (offset, tags) in [ + vec![ + vec!["L", "nip-cl"], + vec!["l", "other", "a"], + vec!["t", "stream"], + ], + vec![ + vec!["L", "other", "nip-cl"], + vec!["l", "a", "nip-cl"], + vec!["t", "stream"], + ], + vec![vec!["L", "nip-cl"], vec!["a", "l"], vec!["t", "stream"]], + vec![ + vec!["L", "nip-cl"], + vec!["l", "a", "nip-cl"], + vec!["t", "other", "stream"], + ], + vec![ + vec!["L", "nip-cl"], + vec!["l", "other", "nip-cl"], + vec!["t", "stream"], + ], + ] + .into_iter() + .enumerate() + { + let refs: Vec<_> = tags.iter().map(Vec::as_slice).collect(); + let candidate = event(&keys, visible, now - offset as u64, &refs); + insert_event(&pool, a, &candidate, Some(visible)) + .await + .unwrap(); + } + let filter: Filter = serde_json::from_value(serde_json::json!({ + "kinds": [39000], "#L": ["nip-cl"], "#l": ["a", "b"], "#t": ["stream"] + })) + .unwrap(); + let mut query = EventQuery::for_community(a); + query.kinds = Some(vec![39000]); + query.channel_ids = Some(vec![visible]); + query.channel_ids_include_global = false; + query.tag_filters = filter + .generic_tags + .iter() + .map(|(key, values)| { + ( + key.to_string(), + values.iter().map(ToString::to_string).collect(), + ) + }) + .collect(); + query.limit = Some(1); + let rows = query_events(&pool, &query).await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].event.id, older.id); + assert!(filters_match(&[filter], &rows[0])); + assert_eq!(count_events(&pool, &query).await.unwrap(), 1); + + query.tag_filters.push(("l".into(), vec![])); + assert!(query_events(&pool, &query).await.unwrap().is_empty()); + assert_eq!(count_events(&pool, &query).await.unwrap(), 0); +} diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 91c4a6e282d..ba768d54f04 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -12,6 +12,8 @@ pub mod archived_identities; pub mod channel; /// Channel membership and roster persistence. pub mod channel_members; +/// Serialized channel metadata and NIP-CL application. +pub mod channel_metadata; /// Community lifecycle and host-map persistence. pub mod community; /// Durable whole-community deletion lifecycle and PostgreSQL adapter. diff --git a/crates/buzz-db/tests/observability_source.rs b/crates/buzz-db/tests/observability_source.rs index 1608779952b..9b83f152039 100644 --- a/crates/buzz-db/tests/observability_source.rs +++ b/crates/buzz-db/tests/observability_source.rs @@ -179,7 +179,19 @@ fn p0_pool_acquisitions_use_typed_operation_pairs_without_other() { let side_effects = include_str!("../../buzz-relay/src/handlers/side_effects.rs"); assert!(side_effects.contains("query_events_for_event_write")); assert!(side_effects.contains("query_events_for_bootstrap")); - assert!(side_effects.contains(".list_channels_for_bootstrap(")); + assert!(side_effects.contains(".channel_metadata_repair_page(")); + let metadata_activation = include_str!("../src/store/channel_metadata/activation.rs"); + let repair_page = metadata_activation + .split_once("pub async fn channel_metadata_repair_page(") + .expect("metadata repair must expose a paged bootstrap read") + .1 + .split_once("/// Verify that every live channel") + .expect("repair paging must precede activation verification") + .0; + assert!(repair_page.contains("WriterOperation::Bootstrap")); + assert!(repair_page.contains("observability::acquire_writer(")); + assert!(repair_page.contains("fetch_all(&mut *connection)")); + assert!(!repair_page.contains("fetch_all(&self.pool)")); let deletion = include_str!("../src/store/deletion.rs"); let public_serving_catalog = deletion diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests.rs index e60010cc8ac..5cf3b141b0a 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests.rs @@ -387,6 +387,7 @@ async fn generic_d_lookup_matches_before_limit() { for filter in [ json!([{"kinds":[45010],"#h":[f.home],"#d":[a],"limit":1}]), json!([{"kinds":[45010,45011],"#h":[f.home],"#d":[a],"limit":1}]), + json!([{"kinds":[45010,45011,39000],"#h":[f.home],"#d":[a],"limit":1}]), json!([{"#h":[f.home],"#p":[reader],"#d":[a],"limit":1}]), ] { let (status, body) = f.request_as(&f.peer, "/query", filter.clone()).await; diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index d631df75003..5d267ab5a95 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1428,10 +1428,14 @@ async fn query_events_authed( } // Get channels this user can access — same enforcement as WS REQ handler. - let mut accessible_channels = state - .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) - .await - .map_err(|e| internal_error(&format!("channel access lookup: {e}")))?; + let mut accessible_channels = crate::handlers::req::accessible_channels_for_filters( + state, + tenant.community(), + &pubkey_bytes, + &filters, + ) + .await + .map_err(|e| internal_error(&format!("channel access lookup: {e}")))?; repair_requested_channel_access( state, tenant, @@ -2024,10 +2028,14 @@ async fn count_events_authed( } // Get channels this user can access. - let mut accessible_channels = state - .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) - .await - .map_err(|e| internal_error(&format!("channel access lookup: {e}")))?; + let mut accessible_channels = crate::handlers::req::accessible_channels_for_filters( + state, + tenant.community(), + &pubkey_bytes, + &filters, + ) + .await + .map_err(|e| internal_error(&format!("channel access lookup: {e}")))?; repair_requested_channel_access( state, tenant, @@ -2950,6 +2958,10 @@ mod artifact_postgres_tests; #[path = "typing_postgres_tests.rs"] mod typing_postgres_tests; +#[cfg(test)] +#[path = "channel_labels_postgres_tests.rs"] +mod channel_labels_postgres_tests; + #[cfg(test)] pub(crate) mod postgres_tests { use super::*; diff --git a/crates/buzz-relay/src/api/channel_labels_postgres_tests.rs b/crates/buzz-relay/src/api/channel_labels_postgres_tests.rs new file mode 100644 index 00000000000..56d1a75a8f5 --- /dev/null +++ b/crates/buzz-relay/src/api/channel_labels_postgres_tests.rs @@ -0,0 +1,431 @@ +//! Real HTTP admission, command application, publication and generic discovery. +//! The fixture enables NIP-CL only here; it does not bypass transport replay checks. +#[path = "channel_labels_postgres_tests/activation_postgres_tests.rs"] +mod activation_postgres_tests; +#[path = "channel_labels_postgres_tests/repair_postgres_tests.rs"] +mod repair_postgres_tests; +#[path = "channel_labels_postgres_tests/ws_postgres_tests.rs"] +mod ws_postgres_tests; + +use super::postgres_tests::bridge_handler_test_state; +use std::sync::Arc; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use buzz_core::{channel_labels::verify_snapshot, CommunityId, TenantContext}; +use nostr::{Event, EventBuilder, Keys, Kind, Tag}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +struct Fixture { + state: Arc, + pool: sqlx::PgPool, + host: String, + community: CommunityId, + owner: Keys, + peer: Keys, +} + +impl Fixture { + async fn new() -> Self { + let mut state = bridge_handler_test_state() + .await + .expect("Postgres and Redis required"); + let inner = Arc::get_mut(&mut state).expect("unshared fixture"); + Arc::get_mut(&mut inner.config) + .expect("unshared config") + .nip_cl_enabled = true; + inner.nip98_replay = Arc::new(buzz_pubsub::RedisNip98ReplayGuard::new( + inner.redis_pool.clone(), + )); + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let host = format!("channel-labels-{}.local", Uuid::new_v4()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + state + .db + .maintain_partitions( + 1, + buzz_db::partition::PartitionMaintenancePolicy { + create_enabled: true, + advance_enabled: false, + }, + ) + .await + .unwrap(); + Self { + state, + pool, + host, + community, + owner: Keys::generate(), + peer: Keys::generate(), + } + } + + fn command(&self, key: &Keys, channel: Uuid, kind: u16, fields: &[(&str, &str)]) -> Event { + EventBuilder::new(Kind::Custom(kind), "") + .tags( + std::iter::once(Tag::parse(["h", &channel.to_string()]).unwrap()).chain( + fields + .iter() + .map(|(name, value)| Tag::parse([*name, *value]).unwrap()), + ), + ) + .sign_with_keys(key) + .unwrap() + } + + async fn request( + &self, + key: &Keys, + host: &str, + path: &str, + body: Value, + ) -> (axum::http::StatusCode, Value) { + use tower::ServiceExt; + let body = body.to_string(); + let auth = EventBuilder::new(Kind::HttpAuth, "") + .tags([ + Tag::parse(["u", &format!("https://{host}{path}")]).unwrap(), + Tag::parse(["method", "POST"]).unwrap(), + Tag::parse(["payload", &hex::encode(Sha256::digest(body.as_bytes()))]).unwrap(), + // Exact command retries still need a fresh transport proof. + Tag::parse(["nonce", &Uuid::new_v4().to_string()]).unwrap(), + ]) + .sign_with_keys(key) + .unwrap(); + let response = crate::router::build_router(self.state.clone()) + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri(path) + .header("host", host) + .header("content-type", "application/json") + .header( + "authorization", + format!( + "Nostr {}", + BASE64.encode(serde_json::to_vec(&auth).unwrap()) + ), + ) + .body(axum::body::Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap(); + ( + status, + serde_json::from_slice(&bytes).expect("JSON response"), + ) + } + + async fn submit(&self, key: &Keys, event: &Event, accepted: bool, prefix: &str) { + let (status, body) = self.request(key, &self.host, "/events", json!(event)).await; + assert!(status.is_success(), "{status}: {body}"); + assert_eq!(body["event_id"], event.id.to_hex(), "{body}"); + assert_eq!(body["accepted"], accepted, "{body}"); + assert!( + body["message"].as_str().unwrap().starts_with(prefix), + "{body}" + ); + if prefix.is_empty() { + assert_eq!(body["message"], ""); + } + } + + async fn read(&self, key: &Keys, path: &str, filter: Value) -> Value { + let (status, body) = self.request(key, &self.host, path, json!([filter])).await; + assert!(status.is_success(), "{status}: {body}"); + body + } + + async fn snapshot(&self, channel: Uuid, labels: &[&str]) -> Event { + let body = self + .read( + &self.owner, + "/query", + json!({"kinds":[39000],"#d":[channel]}), + ) + .await; + assert_eq!(body.as_array().unwrap().len(), 1, "{body}"); + let event: Event = serde_json::from_value(body[0].clone()).unwrap(); + assert_eq!( + verify_snapshot(&event, self.state.relay_keypair.public_key(), channel) + .unwrap() + .values(), + labels + ); + event + } + + async fn create(&self, channel: Uuid, labels: &[&str], visibility: &str) -> Event { + let mut fields = vec![ + ("name", "label-test"), + ("channel_type", "stream"), + ("visibility", visibility), + ]; + fields.extend(labels.iter().map(|label| ("label", *label))); + let event = self.command(&self.owner, channel, 9007, &fields); + self.submit(&self.owner, &event, true, "").await; + event + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn router_commits_retries_and_preserves_labels_through_ordinary_publishers() { + let f = Fixture::new().await; + let channel = Uuid::new_v4(); + let create = f + .create( + channel, + &["team:infra", "status:queued", "team:infra"], + "open", + ) + .await; + let initial = f.snapshot(channel, &["status:queued", "team:infra"]).await; + f.submit(&f.owner, &create, true, "duplicate: nip-cl-committed") + .await; + assert_eq!( + f.snapshot(channel, &["status:queued", "team:infra"]) + .await + .id, + initial.id + ); + + let edit = f.command( + &f.owner, + channel, + 9002, + &[ + ("add-label", "status:active"), + ("remove-label", "status:queued"), + ], + ); + f.submit(&f.owner, &edit, true, "").await; + let changed = f.snapshot(channel, &["status:active", "team:infra"]).await; + assert!(changed.created_at > initial.created_at); + let noop = f.command(&f.owner, channel, 9002, &[("add-label", "team:infra")]); + f.submit(&f.owner, &noop, true, "").await; + assert_eq!( + f.snapshot(channel, &["status:active", "team:infra"]) + .await + .id, + changed.id + ); + + let remove = f.command( + &f.owner, + channel, + 9002, + &[ + ("remove-label", "status:active"), + ("remove-label", "team:infra"), + ], + ); + f.submit(&f.owner, &remove, true, "").await; + let empty = f.snapshot(channel, &[]).await; + assert!(empty.created_at > changed.created_at); + for earlier in [&create, &edit, &noop] { + f.submit(&f.owner, earlier, true, "duplicate: nip-cl-committed") + .await; + assert_eq!(f.snapshot(channel, &[]).await.id, empty.id); + } + let rename = f.command( + &f.owner, + channel, + 9002, + &[("name", "renamed-without-labels")], + ); + f.submit(&f.owner, &rename, true, "").await; + let renamed = f.snapshot(channel, &[]).await; + assert!(renamed.created_at > empty.created_at); + let tenant = TenantContext::resolved(f.community, &f.host); + crate::handlers::side_effects::emit_group_discovery_events(&tenant, &f.state, channel) + .await + .unwrap(); + assert_eq!( + f.snapshot(channel, &[]).await.id, + renamed.id, + "repair must preserve a current head" + ); + + let applied: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id=$1 AND channel_id=$2 AND nip_cl_applied", + ) + .bind(f.community.as_uuid()) + .bind(channel) + .fetch_one(&f.pool) + .await + .unwrap(); + assert_eq!( + applied, 4, + "new no-op commits evidence, retries do not add commands" + ); + let messages: i64 = sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND channel_id=$2 AND kind IN (9, 40002, 40003)") + .bind(f.community.as_uuid()).bind(channel).fetch_one(&f.pool).await.unwrap(); + assert_eq!(messages, 0, "metadata commands do not become chat activity"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn router_discovery_and_count_filter_current_authorized_heads_before_limit() { + let f = Fixture::new().await; + let matching = Uuid::new_v4(); + let nonmatching = Uuid::new_v4(); + let private = Uuid::new_v4(); + f.create(matching, &["team:infra"], "open").await; + let expected = f.snapshot(matching, &["team:infra"]).await; + for (channel, visibility) in [(nonmatching, "open"), (private, "private")] { + f.create(channel, &["team:infra"], visibility).await; + // Force newer heads without sleeping or assuming wall-clock ordering. + let rename = f.command(&f.owner, channel, 9002, &[("name", "newer-head")]); + f.submit(&f.owner, &rename, true, "").await; + assert!(f.snapshot(channel, &["team:infra"]).await.created_at > expected.created_at); + } + let remove = f.command( + &f.owner, + nonmatching, + 9002, + &[("remove-label", "team:infra")], + ); + f.submit(&f.owner, &remove, true, "").await; + // The superseded matching snapshot must not reappear after label removal. + let filter = + json!({"kinds":[39000],"#t":["stream"],"#L":["nip-cl"],"#l":["team:infra"],"limit":1}); + let result = f.read(&f.peer, "/query", filter.clone()).await; + assert_eq!(result.as_array().unwrap().len(), 1, "{result}"); + assert_eq!(result[0]["id"], expected.id.to_hex()); + for mixed in [ + json!({"kinds":[39000, 1],"#L":["nip-cl"],"#l":["team:infra"],"limit":1}), + json!({"ids":[expected.id, f.snapshot(nonmatching, &[]).await.id],"#l":["team:infra"],"limit":1}), + ] { + let result = f.read(&f.peer, "/query", mixed).await; + assert_eq!(result.as_array().unwrap().len(), 1, "{result}"); + assert_eq!(result[0]["id"], expected.id.to_hex()); + } + let count = f.read(&f.peer, "/count", filter.clone()).await; + assert_eq!(count["count"], 1, "{count}"); + let owner_count = f.read(&f.owner, "/count", filter).await; + assert_eq!( + owner_count["count"], 2, + "private fixture must exist and be readable to owner" + ); + let missing = f + .read(&f.peer, "/query", json!({"kinds":[39000],"#d":[private]})) + .await; + assert_eq!(missing, json!([])); + let nonmatch_count = f + .read(&f.peer, "/count", json!({"kinds":[39000],"#l":["absent"]})) + .await; + assert_eq!(nonmatch_count["count"], 0); + + let other_host = format!("labels-bystander-{}.local", Uuid::new_v4()); + f.state + .db + .ensure_configured_community(&other_host) + .await + .unwrap(); + let (status, result) = f + .request( + &f.owner, + &other_host, + "/query", + json!([{"kinds":[39000],"#l":["team:infra"]}]), + ) + .await; + assert!(status.is_success(), "{status}: {result}"); + assert_eq!( + result, + json!([]), + "host-derived community must not read neighbor labels" + ); + let (status, result) = f + .request(&f.owner, &other_host, "/events", json!(remove)) + .await; + assert!(status.is_success(), "{status}: {result}"); + assert_eq!(result["accepted"], false); + assert!(result["message"] + .as_str() + .unwrap() + .starts_with("restricted: nip-cl-rejected")); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn router_rejections_leave_no_partial_state_and_unknown_is_not_a_receipt() { + let f = Fixture::new().await; + let invalid = Uuid::new_v4(); + let create = f.command( + &f.owner, + invalid, + 9007, + &[("name", "invalid-label"), ("label", "Uppercase")], + ); + f.submit(&f.owner, &create, false, "invalid: nip-cl-rejected") + .await; + let count: i64 = + sqlx::query_scalar("SELECT count(*) FROM channels WHERE community_id=$1 AND id=$2") + .bind(f.community.as_uuid()) + .bind(invalid) + .fetch_one(&f.pool) + .await + .unwrap(); + assert_eq!(count, 0); + let channel = Uuid::new_v4(); + let committed = f.create(channel, &[], "open").await; + let before = f.snapshot(channel, &[]).await; + let conflict = f.command(&f.owner, channel, 9007, &[("name", "different-command")]); + f.submit(&f.owner, &conflict, false, "duplicate: nip-cl-rejected") + .await; + let unauthorized = f.command(&f.peer, channel, 9002, &[("add-label", "not-authorized")]); + f.submit(&f.peer, &unauthorized, false, "restricted: nip-cl-rejected") + .await; + let mixed = f.command( + &f.owner, + channel, + 9002, + &[("name", "not-applied"), ("add-label", "mixed")], + ); + f.submit(&f.owner, &mixed, false, "invalid: nip-cl-rejected") + .await; + assert_eq!(f.snapshot(channel, &[]).await.id, before.id); + + let legacy = f.command(&f.owner, channel, 9002, &[("add-label", "legacy")]); + f.state + .db + .insert_event(f.community, &legacy, Some(channel)) + .await + .unwrap(); + f.submit(&f.owner, &legacy, false, "error: nip-cl-unknown") + .await; + assert_eq!(f.snapshot(channel, &[]).await.id, before.id); + // Soft deletion cannot make a previously committed command apply again. + sqlx::query("UPDATE events SET deleted_at=NOW() WHERE community_id=$1 AND id=$2") + .bind(f.community.as_uuid()) + .bind(committed.id.as_bytes().as_slice()) + .execute(&f.pool) + .await + .unwrap(); + f.submit(&f.owner, &committed, true, "duplicate: nip-cl-committed") + .await; + f.state + .db + .archive_channel(f.community, channel) + .await + .unwrap(); + f.submit(&f.owner, &committed, false, "restricted: nip-cl-rejected") + .await; + assert_eq!(f.snapshot(channel, &[]).await.id, before.id); +} diff --git a/crates/buzz-relay/src/api/channel_labels_postgres_tests/activation_postgres_tests.rs b/crates/buzz-relay/src/api/channel_labels_postgres_tests/activation_postgres_tests.rs new file mode 100644 index 00000000000..3267d7c16a7 --- /dev/null +++ b/crates/buzz-relay/src/api/channel_labels_postgres_tests/activation_postgres_tests.rs @@ -0,0 +1,52 @@ +use super::*; + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn capability_is_host_bound_and_disabling_commands_preserves_metadata() { + let mut f = Fixture::new().await; + let mut config = (*f.state.config).clone(); + config.relay_private_key = Some(f.state.relay_keypair.secret_key().to_secret_hex()); + Arc::get_mut(&mut f.state).unwrap().config = Arc::new(config.clone()); + let info = crate::nip11::nip11_document(&f.state, &f.host).await; + assert!(info + .supported_extensions + .unwrap() + .iter() + .any(|ext| ext == "nip-cl")); + let unknown = crate::nip11::nip11_document(&f.state, "unknown.example").await; + assert!(!unknown + .supported_extensions + .unwrap() + .iter() + .any(|ext| ext == "nip-cl")); + let channel = Uuid::new_v4(); + f.create(channel, &["retained"], "open").await; + let before = f.snapshot(channel, &["retained"]).await; + config.nip_cl_enabled = false; + Arc::get_mut(&mut f.state).unwrap().config = Arc::new(config); + let info = crate::nip11::nip11_document(&f.state, &f.host).await; + assert!(!info + .supported_extensions + .unwrap() + .iter() + .any(|ext| ext == "nip-cl")); + let mutation = f.command(&f.owner, channel, 9002, &[("remove-label", "retained")]); + f.submit(&f.owner, &mutation, false, "restricted: nip-cl-rejected") + .await; + let rename = f.command(&f.owner, channel, 9002, &[("name", "after-disable")]); + f.submit(&f.owner, &rename, true, "").await; + let after = f.snapshot(channel, &["retained"]).await; + assert!(after.created_at > before.created_at); + assert!(after + .tags + .iter() + .any(|tag| tag.as_slice() == ["name", "after-disable"])); + let evidence: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2") + .bind(f.community.as_uuid()) + .bind(mutation.id.as_bytes().as_slice()) + .fetch_one(&f.pool) + .await + .unwrap(); + assert_eq!(evidence, 0); +} diff --git a/crates/buzz-relay/src/api/channel_labels_postgres_tests/repair_postgres_tests.rs b/crates/buzz-relay/src/api/channel_labels_postgres_tests/repair_postgres_tests.rs new file mode 100644 index 00000000000..6f0efc6e00f --- /dev/null +++ b/crates/buzz-relay/src/api/channel_labels_postgres_tests/repair_postgres_tests.rs @@ -0,0 +1,53 @@ +//! Startup repair must use the complete catalog, not an interactive page. +use super::*; + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn startup_repairs_past_catalog_cap_including_archived_channels() { + let f = Fixture::new().await; + // Fixture-only SQL: all 1,001 live channels lack discovery. The last live + // channel is archived; the next is a tombstone and must remain absent. + sqlx::query( + "INSERT INTO channels (community_id, id, name, created_by, labels, archived_at, deleted_at) \ + SELECT $1, lpad(to_hex(n), 32, '0')::uuid, 'repair', $2, ARRAY['retained'], \ + CASE WHEN n = 1001 THEN now() END, CASE WHEN n = 1002 THEN now() END \ + FROM generate_series(1, 1002) AS n", + ) + .bind(f.community.as_uuid()) + .bind(f.owner.public_key().as_bytes().as_slice()) + .execute(&f.pool) + .await + .unwrap(); + let tenant = TenantContext::resolved(f.community, &f.host); + crate::handlers::side_effects::reconcile_channel_events(&tenant, &f.state) + .await + .unwrap(); + let heads: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id=$1 AND kind=39000 AND deleted_at IS NULL", + ) + .bind(f.community.as_uuid()) + .fetch_one(&f.pool) + .await + .unwrap(); + assert_eq!( + heads, 1001, + "every live channel must receive canonical metadata" + ); + let archived = f.snapshot(Uuid::from_u128(1001), &["retained"]).await; + assert!(archived + .tags + .iter() + .any(|tag| tag.as_slice() == ["archived", "true"])); + let deleted: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id=$1 AND channel_id=$2 AND deleted_at IS NULL", + ) + .bind(f.community.as_uuid()) + .bind(Uuid::from_u128(1002)) + .fetch_one(&f.pool) + .await + .unwrap(); + assert_eq!( + deleted, 0, + "repair must not resurrect any deleted-channel discovery" + ); +} diff --git a/crates/buzz-relay/src/api/channel_labels_postgres_tests/ws_postgres_tests.rs b/crates/buzz-relay/src/api/channel_labels_postgres_tests/ws_postgres_tests.rs new file mode 100644 index 00000000000..1077b92f0ed --- /dev/null +++ b/crates/buzz-relay/src/api/channel_labels_postgres_tests/ws_postgres_tests.rs @@ -0,0 +1,181 @@ +//! Actual socket upgrade, NIP-42, EVENT/REQ/COUNT and local live delivery over the +//! production router. Real DB/Redis; no fabricated authenticated connection. + +use std::{net::SocketAddr, time::Duration}; + +use futures_util::{SinkExt, StreamExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio_tungstenite::{ + connect_async, + tungstenite::{client::IntoClientRequest, Message}, + MaybeTlsStream, WebSocketStream, +}; + +use super::*; + +type Socket = WebSocketStream>; + +struct Server(tokio::task::JoinHandle>); + +impl Drop for Server { + fn drop(&mut self) { + self.0.abort(); + } +} + +async fn receive(socket: &mut Socket) -> Value { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + match socket.next().await.expect("socket remains open").unwrap() { + Message::Text(text) => return serde_json::from_str(&text).unwrap(), + Message::Ping(_) | Message::Pong(_) => continue, + other => panic!("unexpected frame {other:?}"), + } + } + }) + .await + .expect("relay sends a bounded response") +} + +async fn send(socket: &mut Socket, frame: Value) { + socket + .send(Message::Text(frame.to_string().into())) + .await + .unwrap(); +} + +async fn connect(f: &Fixture, key: &Keys) -> (Server, Socket) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let app = crate::router::build_router(f.state.clone()) + .into_make_service_with_connect_info::(); + let server = Server(tokio::spawn( + async move { axum::serve(listener, app).await }, + )); + let mut request = format!("ws://{address}/").into_client_request().unwrap(); + request + .headers_mut() + .insert("host", f.host.parse().unwrap()); + let (mut socket, _) = connect_async(request).await.unwrap(); + let challenge = receive(&mut socket).await; + assert_eq!(challenge[0], "AUTH", "{challenge}"); + let relay = crate::api::bridge::nip42_expected_relay_url( + &f.state.config.relay_url, + &TenantContext::resolved(f.community, &f.host), + ); + let auth = EventBuilder::auth(challenge[1].as_str().unwrap(), relay.parse().unwrap()) + .sign_with_keys(key) + .unwrap(); + send(&mut socket, json!(["AUTH", auth])).await; + let ack = receive(&mut socket).await; + assert_eq!(ack[0], "OK", "{ack}"); + assert_eq!(ack[1], auth.id.to_hex(), "{ack}"); + assert_eq!(ack[2], true, "{ack}"); + (server, socket) +} + +async fn query(socket: &mut Socket, subscription: &str, filter: Value) -> Vec { + send(socket, json!(["REQ", subscription, filter])).await; + let mut events = Vec::new(); + loop { + let frame = receive(socket).await; + assert_eq!(frame[1], subscription, "{frame}"); + match frame[0].as_str().unwrap() { + "EVENT" => events.push(serde_json::from_value(frame[2].clone()).unwrap()), + "EOSE" => return events, + _ => panic!("unexpected query response {frame}"), + } + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn socket_discovery_ignores_stale_catalog_and_commands_deliver_canonical_live_state() { + let f = Fixture::new().await; + let channel = Uuid::new_v4(); + f.create(channel, &["team:infra"], "private").await; + let original = f.snapshot(channel, &["team:infra"]).await; + let (_server, mut socket) = connect(&f, &f.owner).await; + + // Model another serving replica whose channel-catalog invalidation is late. + let cache_key = (f.community, f.owner.public_key().to_bytes().to_vec()); + f.state + .accessible_channels_cache + .insert(cache_key.clone(), vec![]); + assert_eq!( + f.state.accessible_channels_cache.get(&cache_key), + Some(vec![]) + ); + let filter = json!({"kinds":[39000],"#L":["nip-cl"],"#l":["team:infra"],"limit":1}); + let http = f.read(&f.owner, "/query", filter.clone()).await; + assert_eq!( + http[0]["id"], + original.id.to_hex(), + "HTTP control must see the seeded channel" + ); + let events = query(&mut socket, "catalog", filter.clone()).await; + assert_eq!( + events.len(), + 1, + "WS discovery must not use stale catalog scope" + ); + assert_eq!(events[0].id, original.id); + send(&mut socket, json!(["COUNT", "count", filter])).await; + let count = receive(&mut socket).await; + assert_eq!(count[0], "COUNT", "{count}"); + assert_eq!(count[2]["count"], 1, "{count}"); + send(&mut socket, json!(["CLOSE", "catalog"])).await; + assert_eq!(receive(&mut socket).await, json!(["CLOSED", "catalog", ""])); + + let initial = query(&mut socket, "live", json!({"kinds":[39000],"#h":[channel]})).await; + assert_eq!(initial[0].id, original.id); + let update = f.command(&f.owner, channel, 9002, &[("add-label", "live")]); + send(&mut socket, json!(["EVENT", update])).await; + // Publication and OK may arrive in either order, but both must arrive. + let mut published = None; + let mut acknowledged = false; + for _ in 0..2 { + let frame = receive(&mut socket).await; + match frame[0].as_str().unwrap() { + "EVENT" => { + assert_eq!(frame[1], "live", "{frame}"); + let event: Event = serde_json::from_value(frame[2].clone()).unwrap(); + assert_eq!( + verify_snapshot(&event, f.state.relay_keypair.public_key(), channel) + .unwrap() + .values(), + &["live", "team:infra"] + ); + assert!(event.created_at > original.created_at); + published = Some(event); + } + "OK" => { + assert_eq!(frame, json!(["OK", update.id, true, ""])); + acknowledged = true; + } + _ => panic!("unexpected mutation response {frame}"), + } + } + assert!(acknowledged && published.is_some()); + let published = published.unwrap(); + assert_eq!( + f.snapshot(channel, &["live", "team:infra"]).await.id, + published.id + ); + send(&mut socket, json!(["EVENT", update])).await; + assert_eq!( + receive(&mut socket).await, + json!(["OK", update.id, true, "duplicate: nip-cl-committed"]) + ); + send(&mut socket, json!(["CLOSE", "live"])).await; + assert_eq!(receive(&mut socket).await, json!(["CLOSED", "live", ""])); + let exact = query( + &mut socket, + "exact", + json!({"kinds":[39000],"#d":[channel]}), + ) + .await; + assert_eq!(exact.len(), 1); + assert_eq!(exact[0].id, published.id); + socket.close(None).await.unwrap(); +} diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 44662bb9e10..a58938d36ef 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -1,5 +1,7 @@ //! Relay configuration from environment variables. +mod channel_labels; + use std::time::Duration; use std::{collections::HashMap, net::SocketAddr}; @@ -331,6 +333,9 @@ pub struct Config { /// 60 seconds after the last message. pub ephemeral_ttl_override: Option, + /// NIP-CL command/advertisement gate. Activation requires an offline writer cutover. + pub nip_cl_enabled: bool, + /// Root directory for the relay's local git scratch. No authoritative /// repository state lives here — runtime reads/writes hydrate ephemeral /// repos from object storage per request. Temporary workspaces, buffered @@ -916,6 +921,7 @@ impl Config { .collect(); let relay_private_key = std::env::var("BUZZ_RELAY_PRIVATE_KEY").ok(); + let nip_cl_enabled = channel_labels::enabled_from_env(relay_private_key.is_some())?; let uds_path = std::env::var("BUZZ_UDS_PATH") .ok() @@ -1418,6 +1424,7 @@ impl Config { media_uploads_per_minute, audit_enabled, ephemeral_ttl_override, + nip_cl_enabled, git_repo_path, git_pack_cache_path, git_max_pack_bytes, diff --git a/crates/buzz-relay/src/config/channel_labels.rs b/crates/buzz-relay/src/config/channel_labels.rs new file mode 100644 index 00000000000..570140d7eb4 --- /dev/null +++ b/crates/buzz-relay/src/config/channel_labels.rs @@ -0,0 +1,46 @@ +//! Activation is an operator cutover, not a rolling feature flag. + +use super::{parse_bool, ConfigError}; + +pub(super) fn enabled_from_env(stable_key: bool) -> Result { + let enabled = parse_bool("BUZZ_NIP_CL_ENABLED", false)?; + let cutover = std::env::var("BUZZ_NIP_CL_WRITER_CUTOVER").ok(); + validate_activation(enabled, cutover.as_deref(), stable_key) +} + +fn validate_activation( + enabled: bool, + cutover: Option<&str>, + stable_key: bool, +) -> Result { + if enabled && (!stable_key || cutover != Some("offline-v1")) { + return Err(ConfigError::InvalidValue( + "BUZZ_NIP_CL_ENABLED requires BUZZ_RELAY_PRIVATE_KEY and \ + BUZZ_NIP_CL_WRITER_CUTOVER=offline-v1 after completing \ + docs/channel-labels-rollout.md; the declaration does not fence old writers" + .into(), + )); + } + Ok(enabled) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn activation_requires_both_stable_identity_and_explicit_cutover() { + for enabled in [false, true] { + for stable_key in [false, true] { + for cutover in [None, Some(""), Some("true"), Some("offline-v1")] { + let result = validate_activation(enabled, cutover, stable_key); + if enabled && !(stable_key && cutover == Some("offline-v1")) { + assert!(result.is_err()); + } else { + assert_eq!(result.unwrap(), enabled); + } + } + } + } + } +} diff --git a/crates/buzz-relay/src/handlers/channel_labels.rs b/crates/buzz-relay/src/handlers/channel_labels.rs new file mode 100644 index 00000000000..e4a3da4a9f0 --- /dev/null +++ b/crates/buzz-relay/src/handlers/channel_labels.rs @@ -0,0 +1,242 @@ +//! NIP-CL is a transaction, never generic event storage followed by side effects. + +use std::sync::Arc; + +use buzz_auth::Scope; +use buzz_core::{channel::ChannelVisibility, channel_labels::LabelCommand, TenantContext}; +use buzz_db::channel_metadata::{ChannelCreate, ChannelMetadataWrite, CommandApplication}; +use nostr::Event; +use tracing::warn; + +use super::ingest::{IngestAuth, IngestError, IngestResult}; +use crate::state::AppState; + +/// Classify commands only after generic signature/auth/scope/write admission. +/// Returns `None` for ordinary metadata and the legacy no-h creation path. +pub(crate) async fn handle_if_covered( + tenant: &TenantContext, + state: &Arc, + event: &Event, + auth: &IngestAuth, +) -> Option> { + let command = match LabelCommand::parse(event) { + Ok(Some(command)) => command, + Ok(None) => return None, + Err(error) => { + return Some(Ok(response( + event, + false, + format!("invalid: nip-cl-rejected {error}"), + ))) + } + }; + if !state.config.nip_cl_enabled { + // Until fleet activation, existing unlabeled creation retains its old + // contract. Never accept label operations and silently drop their state. + if matches!(&command, LabelCommand::Create { labels, .. } if labels.is_empty()) { + return None; + } + return Some(Ok(response( + event, + false, + "restricted: nip-cl-rejected channel labels are disabled".into(), + ))); + } + Some(Ok(apply(tenant, state, event, auth, command).await)) +} + +async fn apply( + tenant: &TenantContext, + state: &Arc, + event: &Event, + auth: &IngestAuth, + command: LabelCommand, +) -> IngestResult { + let channel = command.channel(); + if !auth.scopes().contains(&Scope::ChannelsWrite) + || auth + .channel_ids() + .is_some_and(|ids| !ids.contains(&channel)) + { + return response( + event, + false, + "restricted: nip-cl-rejected insufficient scope".into(), + ); + } + let create = if matches!(command, LabelCommand::Create { .. }) { + match creation_fields(event, state) { + Ok(fields) => Some(fields), + Err(detail) => { + return response(event, false, format!("invalid: nip-cl-rejected {detail}")) + } + } + } else { + None + }; + let mut write = match state + .db + .begin_channel_metadata_write( + tenant.community(), + channel, + state.relay_keypair.public_key(), + ) + .await + { + Ok(write) => write, + Err(error) => { + warn!(%error, "channel label transaction admission failed"); + return response( + event, + false, + "error: nip-cl-rejected admission failed".into(), + ); + } + }; + let decision = match write.apply_command(event, create.as_ref()).await { + Ok(decision) => decision, + Err(error) => { + warn!(%error, "channel label application failed"); + let prefix = if matches!(error, buzz_db::DbError::InvalidData(_)) { + "invalid: nip-cl-rejected invalid command or resulting state" + } else { + "error: nip-cl-rejected application failed" + }; + return rollback_response(event, write, prefix).await; + } + }; + if decision != CommandApplication::Applied { + let message = match decision { + CommandApplication::Committed => "duplicate: nip-cl-committed", + CommandApplication::Unknown => "error: nip-cl-unknown application evidence unavailable", + CommandApplication::Conflict => { + "duplicate: nip-cl-rejected channel UUID is unavailable" + } + CommandApplication::Restricted => "restricted: nip-cl-rejected current access denied", + CommandApplication::Expired => { + "invalid: nip-cl-rejected timestamp outside admission window" + } + CommandApplication::Applied => unreachable!(), + }; + if let Err(error) = write.rollback().await { + warn!(%error, "channel label read-only transaction rollback failed"); + } + return response( + event, + decision == CommandApplication::Committed, + message.into(), + ); + } + if let Err(error) = super::channel_metadata::store_snapshot_if_needed(state, &mut write).await { + warn!(%error, "channel label snapshot failed"); + return rollback_response(event, write, "error: nip-cl-rejected snapshot failed").await; + } + let published = match write.commit().await { + Ok(published) => published, + Err(error) => { + warn!(%error, "channel label commit outcome uncertain"); + return response( + event, + false, + "error: nip-cl-unknown commit outcome uncertain".into(), + ); + } + }; + // Do not send the command through generic post-storage handlers: that would + // repeat creation or turn metadata tags into conversation activity. + if let Some(stored) = published { + super::event::dispatch_persistent_event( + tenant, + state, + &stored, + buzz_core::kind::KIND_NIP29_GROUP_METADATA, + &state.relay_keypair.public_key().to_hex(), + None, + ) + .await; + } + if let Some(create) = create { + state.invalidate_membership(tenant, channel, event.pubkey.as_bytes()); + if create.visibility == ChannelVisibility::Open { + state.invalidate_all_accessible_channels(tenant); + } + metrics::counter!("buzz_channels_created_total", "community" => tenant.host().to_owned(), + "type" => create.channel_type.to_string()) + .increment(1); + // These auxiliary snapshots/notifications are not part of NIP-CL's + // atomic acknowledgement. Failure cannot undo the committed command. + if let Err(error) = + super::side_effects::emit_group_discovery_events(tenant, state, channel).await + { + warn!(%error, "post-create discovery failed"); + } + if let Err(error) = super::side_effects::emit_membership_notification( + tenant, + state, + channel, + event.pubkey.as_bytes(), + event.pubkey.as_bytes(), + buzz_core::kind::KIND_MEMBER_ADDED_NOTIFICATION, + ) + .await + { + warn!(%error, "post-create membership notification failed"); + } + } + response(event, true, String::new()) +} + +async fn rollback_response( + event: &Event, + write: ChannelMetadataWrite, + rejected: &str, +) -> IngestResult { + match write.rollback().await { + Ok(()) => response(event, false, rejected.into()), + Err(error) => { + warn!(%error, "channel label rollback could not be confirmed"); + response( + event, + false, + "error: nip-cl-unknown rollback could not be confirmed".into(), + ) + } + } +} + +fn response(event: &Event, accepted: bool, message: String) -> IngestResult { + IngestResult { + event_id: event.id.to_hex(), + accepted, + message, + } +} + +fn creation_fields(event: &Event, state: &AppState) -> Result { + let value = |name: &str| { + event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + (parts[0] == name) + .then(|| parts.get(1).map(String::as_str)) + .flatten() + }) + }; + let name = value("name").ok_or("channel name is required")?; + let name = buzz_core::channel::canonical_channel_name(name); + if name.trim().is_empty() || name.chars().count() > 255 { + return Err("invalid channel name"); + } + Ok(ChannelCreate { + name: name.to_owned(), + channel_type: value("channel_type") + .unwrap_or("stream") + .parse() + .map_err(|_| "invalid channel type")?, + visibility: value("visibility") + .unwrap_or("open") + .parse() + .map_err(|_| "invalid visibility")?, + description: value("about").map(str::to_owned), + ttl_seconds: super::resolve_ttl(event, state.config.ephemeral_ttl_override), + }) +} diff --git a/crates/buzz-relay/src/handlers/channel_metadata.rs b/crates/buzz-relay/src/handlers/channel_metadata.rs new file mode 100644 index 00000000000..a29641a4538 --- /dev/null +++ b/crates/buzz-relay/src/handlers/channel_metadata.rs @@ -0,0 +1,70 @@ +//! Relay signing and post-commit dispatch for the shared metadata write boundary. + +use std::sync::Arc; + +use buzz_core::{kind::KIND_NIP29_GROUP_METADATA, TenantContext}; +use buzz_db::channel_metadata::ChannelMetadataWrite; +use nostr::{EventBuilder, Kind}; +use uuid::Uuid; + +use crate::state::AppState; + +/// Sign the locked current projection if it is missing or stale. +/// +/// This stages storage only. The transaction owner commits before dispatching. +pub(crate) async fn store_snapshot_if_needed( + state: &Arc, + write: &mut ChannelMetadataWrite, +) -> anyhow::Result<()> { + if !write.needs_snapshot().await? { + return Ok(()); + } + let tags = write.snapshot_tags().await?; + let timestamp = write.snapshot_timestamp()?; + let keys = state.relay_keypair.clone(); + let event = tokio::task::spawn_blocking(move || { + EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_METADATA as u16), "") + .tags(tags) + .allow_self_tagging() + .custom_created_at(timestamp) + .sign_with_keys(&keys) + }) + .await??; + write + .store_snapshot(&event, state.config.max_frame_bytes) + .await?; + Ok(()) +} + +/// Reconcile complete metadata under its transaction lock, then dispatch a new head. +/// Returns whether a replacement was committed. Deleted channels are never repaired. +pub(crate) async fn publish_metadata( + tenant: &TenantContext, + state: &Arc, + channel_id: Uuid, +) -> anyhow::Result { + let mut write = state + .db + .begin_channel_metadata_write( + tenant.community(), + channel_id, + state.relay_keypair.public_key(), + ) + .await?; + store_snapshot_if_needed(state, &mut write).await?; + let published = write.commit().await?; + if let Some(stored) = published { + super::event::dispatch_persistent_event( + tenant, + state, + &stored, + KIND_NIP29_GROUP_METADATA, + &state.relay_keypair.public_key().to_hex(), + None, + ) + .await; + Ok(true) + } else { + Ok(false) + } +} diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 4dca0beb7f6..a233c0775e0 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -80,9 +80,13 @@ pub async fn handle_count( }; // Get channels this user can access — same enforcement as WS REQ handler. - let mut accessible_channels = match state - .get_accessible_channel_ids_cached(conn.tenant.community(), &pubkey_bytes) - .await + let mut accessible_channels = match super::req::accessible_channels_for_filters( + &state, + conn.tenant.community(), + &pubkey_bytes, + &filters, + ) + .await { Ok(ids) => ids, Err(e) => { diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 49b906ee3d8..c70fcd411b2 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2535,6 +2535,33 @@ async fn ingest_event_inner( // (commands, feedback, reports, moderation) so no write returns ahead of it. enforce_write_restriction(state, tenant, kind_u32, auth.pubkey()).await?; + // NIP-CL owns covered commands end-to-end. It must precede cached channel + // admission, pre-creation, generic dedup and post-storage side effects. + if let Some(result) = + super::channel_labels::handle_if_covered(tenant, state, &event, &auth).await + { + let result = result?; + if result.accepted { + let channel = extract_channel_id(&event) + .ok_or_else(|| IngestError::Internal("error: missing command channel".into()))?; + let action = if result.message.starts_with("duplicate:") { + TraceAction::WriteDuplicate { + msg_id: msg_id_label(event.id.as_bytes()), + channel: channel_label(channel), + claimed_community: claimed_community_from_event(&event), + } + } else { + TraceAction::WriteInsert { + msg_id: msg_id_label(event.id.as_bytes()), + channel: channel_label(channel), + claimed_community: claimed_community_from_event(&event), + } + }; + emit(tracer, action, state_for_request(tenant, auth.pubkey())); + } + return Ok(result); + } + // Command kinds are routed AFTER signature verification, timestamp check, // pubkey/auth match, and scope validation — never before. if buzz_core::kind::is_command_kind(kind_u32) { diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index e4eea46906c..41f74b35afe 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -4,6 +4,10 @@ pub mod admin_outbox_worker; pub mod auth; /// Pure NIP-29 channel membership-authority decisions (kinds 9000/9001/9022). pub mod channel_authz; +/// Atomic NIP-CL command application and exact-event retry. +pub mod channel_labels; +/// Fresh, serialized NIP-29 channel metadata publication. +pub mod channel_metadata; /// Subscription close (CLOSE) handler. pub mod close; /// Command executor — transactional processing for command kinds. diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index c2296ee717d..e655fdf46bd 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -111,9 +111,13 @@ pub async fn handle_req( .increment(1); Vec::new() } else { - match state - .get_accessible_channel_ids_cached(conn.tenant.community(), &pubkey_bytes) - .await + match accessible_channels_for_filters( + &state, + conn.tenant.community(), + &pubkey_bytes, + &filters, + ) + .await { Ok(ids) => ids, Err(e) => { @@ -411,6 +415,7 @@ pub async fn handle_req( }; let mut params = filter_to_query_params(filter, per_filter_channel, conn.tenant.community()); + apply_metadata_read_context(&mut params, filter, &pubkey_bytes, &state); params.before_id = before_ids.get(idx).cloned().flatten(); apply_channel_scope_to_query( &mut params, @@ -911,18 +916,71 @@ async fn handle_search_req( conn.send(RelayMessage::eose(sub_id)); } +// Metadata-only COUNT can use the exact SQL path. Mixed-kind COUNT retains +// its bounded fallback, even though metadata-capable reads push ordinary tags. +fn filter_is_channel_metadata_only(filter: &Filter) -> bool { + filter.kinds.as_ref().is_some_and(|kinds| { + kinds.len() == 1 + && kinds + .iter() + .all(|kind| kind.as_u16() as u32 == buzz_core::kind::KIND_NIP29_GROUP_METADATA) + }) +} + +// Metadata is mutable authorization-sensitive state, including in mixed/id reads. +fn filter_can_match_channel_metadata(filter: &Filter) -> bool { + filter.kinds.as_ref().is_none_or(|kinds| { + kinds + .iter() + .any(|kind| kind.as_u16() as u32 == buzz_core::kind::KIND_NIP29_GROUP_METADATA) + }) +} + +fn apply_metadata_read_context( + query: &mut EventQuery, + filter: &Filter, + reader: &[u8], + state: &AppState, +) { + if filter_can_match_channel_metadata(filter) { + query.channel_metadata_read = Some(buzz_db::event::ChannelMetadataRead { + reader: reader.to_vec(), + relay: state.relay_keypair.public_key().to_bytes().to_vec(), + }); + } +} + +/// Metadata discovery cannot lose newly accessible channels to a stale catalog. +/// SQL independently rechecks the ACL so revocations after this read fail closed. +pub(crate) async fn accessible_channels_for_filters( + state: &AppState, + community: buzz_core::CommunityId, + reader: &[u8], + filters: &[Filter], +) -> buzz_db::Result> { + if filters.iter().any(filter_can_match_channel_metadata) { + state.db.get_accessible_channel_ids(community, reader).await + } else { + state + .get_accessible_channel_ids_cached(community, reader) + .await + } +} + /// Convert a single NIP-01 filter into an [`EventQuery`] for the database. /// /// Public wrapper for use by the HTTP bridge and COUNT handler. /// Resolves accessible channels for the given pubkey and builds the query. pub async fn build_event_query_from_filter( filter: &Filter, - _pubkey_bytes: &[u8], - _state: &AppState, + pubkey_bytes: &[u8], + state: &AppState, community: buzz_core::tenant::CommunityId, ) -> EventQuery { let channel_id = extract_channel_id_from_filter(filter); - filter_to_query_params(filter, channel_id, community) + let mut query = filter_to_query_params(filter, channel_id, community); + apply_metadata_read_context(&mut query, filter, pubkey_bytes, state); + query } /// Maximum SQL candidate rows a non-pushable COUNT filter may inspect before @@ -951,11 +1009,15 @@ pub(crate) fn count_fallback_exceeded(candidate_count: usize) -> bool { /// /// Pushed constraints: kinds, authors (single or multi), ids, since, until, /// authorized channel scope (#h single or multi, injected by caller), #p (single), -/// #d (single, NIP-33-only kinds), #e (any). +/// #d (single, NIP-33-only kinds), #e (any). Metadata-only filters push every +/// generic tag using exact first-value matching. /// /// Anything else (multi-#p, #t, #a, search, #d on non-NIP-33) requires /// post-filtering and cannot use the fast COUNT path. pub fn filter_fully_pushable(filter: &Filter) -> bool { + if filter_is_channel_metadata_only(filter) { + return filter.search.is_none(); + } // Check if filter exclusively targets NIP-33 kinds (needed for #d pushability). let is_nip33_only = filter.kinds.as_ref().is_some_and(|ks| { !ks.is_empty() @@ -1161,7 +1223,7 @@ fn filter_to_query_params( .filter(|values| !values.is_empty()) .map(|values| values.iter().map(|v| v.to_string()).collect()); - EventQuery { + let mut query = EventQuery { channel_id, kinds, pubkey, @@ -1176,7 +1238,41 @@ fn filter_to_query_params( e_tags, d_tag_values, ..EventQuery::for_community(community) + }; + if filter_can_match_channel_metadata(filter) { + query.tag_filters = filter + .generic_tags + .iter() + // #h also matches the stored channel coordinate for metadata without + // an h tag. The caller supplies that authorized scope separately. + .filter(|(key, _)| key.to_string() != "h") + .map(|(key, values)| { + ( + key.to_string(), + values.iter().map(ToString::to_string).collect(), + ) + }) + .collect(); + // These optimizations use derived columns / indexes rather than exact + // first-value matching. The predicates above cover them without losing + // metadata whose p value is not a valid indexed mention, for example. + if filter + .authors + .as_ref() + .is_some_and(|authors| authors.is_empty()) + { + query.authors = Some(Vec::new()); + } + if filter.ids.as_ref().is_some_and(|ids| ids.is_empty()) { + query.ids = Some(Vec::new()); + } + query.p_tag_hex = None; + query.e_tags = None; + query.d_tag = None; + query.d_tags = None; + query.d_tag_values = None; } + query } /// Push channel constraints into SQL before `LIMIT`. @@ -2460,9 +2556,17 @@ mod tests { let mixed = artifact.clone().kind(nostr::Kind::Custom(45011)); let q = filter_to_query_params(&mixed, None, community); assert_eq!(q.d_tag_values, Some(vec!["a".to_string()])); - let kindless = Filter::new().custom_tag(d, "a").limit(1); - let q = filter_to_query_params(&kindless, None, community); - assert_eq!(q.d_tag_values, Some(vec!["a".to_string()])); + // Kindless and metadata-mixed reads use exact generic predicates for + // every kind, which also filter artifact d tags before LIMIT. + for filter in [ + Filter::new().custom_tag(d, "a").limit(1), + mixed.kind(nostr::Kind::Custom(39000)), + ] { + let q = filter_to_query_params(&filter, None, community); + assert_eq!(q.tag_filters, vec![("d".into(), vec!["a".into()])]); + assert_eq!(q.limit, Some(1)); + assert!(q.d_tag_values.is_none()); + } // Filters that cannot select artifacts keep the generic post-filter path. let other = Filter::new() @@ -3827,3 +3931,6 @@ mod tests { } } } + +#[cfg(test)] +mod metadata_filter_tests; diff --git a/crates/buzz-relay/src/handlers/req/metadata_filter_tests.rs b/crates/buzz-relay/src/handlers/req/metadata_filter_tests.rs new file mode 100644 index 00000000000..4c00f6727a9 --- /dev/null +++ b/crates/buzz-relay/src/handlers/req/metadata_filter_tests.rs @@ -0,0 +1,97 @@ +//! The generic WS/HTTP query builder must push metadata predicates before LIMIT. + +use super::*; +use buzz_core::CommunityId; +use serde_json::json; + +#[test] +fn metadata_discovery_pushes_all_tag_fields_for_query_and_count() { + let channel = uuid::Uuid::new_v4(); + let filter: Filter = serde_json::from_value(json!({ + "kinds": [39000], "#L": ["nip-cl"], "#l": ["a", "b"], + "#t": ["stream", "forum"], "#d": [channel.to_string()], + "#p": ["not-a-pubkey"], "#e": ["not-an-event-id"], "#x": ["custom"], + "limit": 1 + })) + .unwrap(); + let query = filter_to_query_params(&filter, None, CommunityId::from_uuid(uuid::Uuid::new_v4())); + assert!(filter_fully_pushable(&filter)); + assert_eq!(query.limit, Some(1)); + assert_eq!(query.tag_filters.len(), filter.generic_tags.len()); + for (key, values) in &filter.generic_tags { + let pushed = query + .tag_filters + .iter() + .find(|(name, _)| name == &key.to_string()) + .unwrap(); + assert_eq!( + pushed.1.iter().collect::>(), + values.iter().collect() + ); + } + assert!(query.p_tag_hex.is_none()); + assert!(query.e_tags.is_none()); + assert!(query.d_tag.is_none()); +} + +#[test] +fn metadata_empty_constraints_stay_match_nothing_and_h_stays_scoped() { + let community = CommunityId::from_uuid(uuid::Uuid::new_v4()); + let channel = uuid::Uuid::new_v4(); + for field in ["#l", "#L", "#t", "#d", "#p", "#e", "#x"] { + let filter: Filter = serde_json::from_value(json!({"kinds": [39000], field: []})).unwrap(); + let query = filter_to_query_params(&filter, None, community); + assert_eq!(query.tag_filters, vec![(field[1..].to_string(), vec![])]); + assert!(filter_fully_pushable(&filter)); + } + for field in ["ids", "authors"] { + let filter: Filter = serde_json::from_value(json!({"kinds": [39000], field: []})).unwrap(); + let query = filter_to_query_params(&filter, None, community); + assert!( + query.ids.as_ref().is_some_and(Vec::is_empty) + || query.authors.as_ref().is_some_and(Vec::is_empty) + ); + } + let filter: Filter = + serde_json::from_value(json!({"kinds": [39000], "#h": [], "#l": ["a"]})).unwrap(); + let mut query = filter_to_query_params(&filter, None, community); + apply_channel_scope_to_query(&mut query, &filter, None, &[channel]); + assert_eq!(query.channel_ids, Some(vec![])); + assert!(!query.channel_ids_include_global); + assert_eq!( + query.tag_filters, + vec![("l".to_string(), vec!["a".to_string()])] + ); +} + +#[test] +fn other_kind_and_search_paths_do_not_claim_metadata_pushability() { + for kinds in [json!([1]), json!([])] { + let filter: Filter = serde_json::from_value(json!({"kinds": kinds, "#l": ["a"]})).unwrap(); + assert!(!filter_fully_pushable(&filter)); + let query = + filter_to_query_params(&filter, None, CommunityId::from_uuid(uuid::Uuid::new_v4())); + assert!(query.tag_filters.is_empty()); + } + let filter: Filter = serde_json::from_value(json!({"kinds": [39000], "search": "a"})).unwrap(); + assert!(!filter_fully_pushable(&filter)); +} + +#[test] +fn mixed_and_id_only_metadata_reads_push_tags_before_limit() { + for mut value in [ + json!({"kinds":[39000, 1]}), + json!({"ids":["11".repeat(32)]}), + ] { + value["#l"] = json!(["retained"]); + value["limit"] = json!(1); + let filter: Filter = serde_json::from_value(value).unwrap(); + let query = + filter_to_query_params(&filter, None, CommunityId::from_uuid(uuid::Uuid::new_v4())); + assert_eq!( + query.tag_filters, + vec![("l".into(), vec!["retained".into()])] + ); + assert_eq!(query.limit, Some(1)); + } +} diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 250b289f446..dbc1883dcc6 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -10,8 +10,7 @@ use buzz_core::kind::{ event_kind_u32, is_parameterized_replaceable, KIND_AGENT_PROFILE, KIND_DM_VISIBILITY, KIND_GIT_REPO_ANNOUNCEMENT, KIND_IA_ARCHIVED, KIND_IA_ARCHIVED_LIST, KIND_IA_UNARCHIVED, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, - KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, - KIND_THREAD_SUMMARY, + KIND_NIP29_GROUP_MEMBERS, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, KIND_THREAD_SUMMARY, }; use buzz_core::StoredEvent; use buzz_db::channel::{MemberRecord, MemberRole}; @@ -1191,81 +1190,16 @@ pub async fn emit_group_discovery_events( state: &Arc, channel_id: Uuid, ) -> anyhow::Result<()> { - let channel = state - .db - .get_channel_for_event_write(tenant.community(), channel_id) - .await?; + // Metadata must be captured only after acquiring the shared application-owned + // transaction boundary. Never give captured stale tags a newer timestamp. + super::channel_metadata::publish_metadata(tenant, state, channel_id).await?; let members = state .db .get_members_for_event_write(tenant.community(), channel_id) .await?; - - let relay_pubkey_hex = hex::encode(state.relay_keypair.public_key().to_bytes()); + let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); let group_id = channel_id.to_string(); - { - let mut tags: Vec = vec![Tag::parse(["d", &group_id])?]; - tags.push(Tag::parse(["name", &channel.name])?); - if let Some(ref desc) = channel.description { - if !desc.is_empty() { - tags.push(Tag::parse(["about", desc])?); - } - } - if channel.visibility == "private" { - tags.push(Tag::parse(["private"])?); - } else { - // Explicit "public" tag complements NIP-29's absence-of-"private" convention, - // making channel visibility self-describing for clients. - tags.push(Tag::parse(["public"])?); - } - // NIP-29 hidden tag: hint to clients not to show DMs in public group lists. - // Not a security boundary — access control is handled by channel-scoped storage. - if channel.channel_type == "dm" { - tags.push(Tag::parse(["hidden"])?); - // Include participant pubkeys in kind:39000 for DMs so clients can - // resolve display names without a separate kind:39002 fetch. - for m in &members { - let pubkey_hex = hex::encode(&m.pubkey); - tags.push(Tag::parse(["p", &pubkey_hex])?); - } - } - // Buzz channels always require explicit membership - tags.push(Tag::parse(["closed"])?); - // Channel type tag so clients can distinguish stream/forum/dm without inference - tags.push(Tag::parse(["t", &channel.channel_type])?); - // Optional topic / purpose for richer client UX - if let Some(ref topic) = channel.topic { - if !topic.is_empty() { - tags.push(Tag::parse(["topic", topic])?); - } - } - if let Some(ref purpose) = channel.purpose { - if !purpose.is_empty() { - tags.push(Tag::parse(["purpose", purpose])?); - } - } - // Archived state — clients use this to hide channels from the sidebar. - if channel.archived_at.is_some() { - tags.push(Tag::parse(["archived", "true"])?); - } - // Ephemeral channel TTL — clients use this to show countdown timers. - if let Some(ttl) = channel.ttl_seconds { - tags.push(Tag::parse(["ttl", &ttl.to_string()])?); - } - if let Some(ref deadline) = channel.ttl_deadline { - tags.push(Tag::parse(["ttl_deadline", &deadline.to_rfc3339()])?); - } - emit_addressable_discovery_event( - tenant, - state, - channel_id, - KIND_NIP29_GROUP_METADATA, - tags, - &relay_pubkey_hex, - ) - .await?; - } - { let mut tags: Vec = vec![Tag::parse(["d", &group_id])?]; for m in members @@ -1287,8 +1221,8 @@ pub async fn emit_group_discovery_events( } // Re-capture membership behind the writer lock immediately before the - // authoritative 39002 replacement. Metadata/admin snapshots retain their - // existing behavior; only membership publication needs this freshness fence. + // authoritative 39002 replacement. Metadata has its own fresh projection + // boundary above; admin-list publication retains its existing behavior. let relay_pubkey = state.relay_keypair.public_key().to_bytes(); let mut member_snapshot = state .db @@ -3407,67 +3341,62 @@ pub async fn reconcile_large_channel_member_snapshots( }) } -/// Reconcile channels that exist in the DB but don't have kind:39000 events. -/// -/// This handles the case where channels were created via direct SQL inserts -/// (e.g. test seed scripts) rather than through the Nostr event pipeline. -/// Emits kind:39000 (metadata) and kind:39002 (members) for each channel -/// that is missing its discovery events. -/// -/// Idempotent: checks for existing kind:39000 events before emitting. +/// Repair missing or stale canonical metadata, not merely missing event rows. +/// Metadata publication rechecks lifecycle under lock, so deletion cannot race +/// startup into resurrecting a channel snapshot. pub async fn reconcile_channel_events( tenant: &TenantContext, state: &Arc, ) -> anyhow::Result<()> { - use buzz_db::event::EventQuery; - - let channels = state - .db - .list_channels_for_bootstrap(tenant.community(), None) - .await?; - if channels.is_empty() { - return Ok(()); - } - - let mut reconciled = 0u32; - for channel in &channels { - // Check if kind:39000 event already exists for this channel. - let channel_id_str = channel.id.to_string(); - let existing = match state + let mut cursor = Uuid::nil(); + let mut reconciled = 0u64; + loop { + let channels = state .db - .query_events_for_bootstrap(&EventQuery { - kinds: Some(vec![39000]), - d_tag: Some(channel_id_str.clone()), - limit: Some(1), - ..EventQuery::for_community(tenant.community()) - }) - .await - { - Ok(v) => v, - Err(e) => { - tracing::warn!( - channel_id = %channel.id, - error = %e, - "reconcile: failed to query existing discovery events" - ); - continue; + .channel_metadata_repair_page(tenant.community(), cursor) + .await?; + if channels.is_empty() { + break; + } + for channel in channels { + cursor = channel; + let result = async { + let repaired = + super::channel_metadata::publish_metadata(tenant, state, channel).await?; + // Metadata and auxiliary discovery commit independently. Check the + // latter independently too, so a previous partial bootstrap heals. + let discovery = state + .db + .query_events_for_bootstrap(&buzz_db::event::EventQuery { + kinds: Some(vec![ + KIND_NIP29_GROUP_ADMINS as i32, + KIND_NIP29_GROUP_MEMBERS as i32, + ]), + authors: Some(vec![state.relay_keypair.public_key().to_bytes().to_vec()]), + d_tag: Some(channel.to_string()), + ..buzz_db::event::EventQuery::for_community(tenant.community()) + }) + .await?; + let has_admins = discovery + .iter() + .any(|stored| event_kind_u32(&stored.event) == KIND_NIP29_GROUP_ADMINS); + let has_members = discovery + .iter() + .any(|stored| event_kind_u32(&stored.event) == KIND_NIP29_GROUP_MEMBERS); + if !has_admins || !has_members { + emit_group_discovery_events(tenant, state, channel).await?; + } + Ok::<_, anyhow::Error>(repaired || !has_admins || !has_members) } - }; - - if existing.is_empty() { - // No discovery event — emit one. - if let Err(e) = emit_group_discovery_events(tenant, state, channel.id).await { - tracing::warn!( - channel_id = %channel.id, - error = %e, - "reconcile: failed to emit discovery events" - ); - } else { - reconciled += 1; + .await; + match result { + Ok(true) => reconciled += 1, + Ok(false) => {} + Err(error) => tracing::warn!(channel_id = %channel, %error, + "reconcile: failed to repair channel discovery"), } } } - if reconciled > 0 { tracing::info!(count = reconciled, "reconciled channel discovery events"); } diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index e42c6a49756..3ab5a8cb907 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -481,6 +481,15 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { info!("Skipping database migrations because BUZZ_AUTO_MIGRATE is not enabled"); } + // Fail closed before listeners/background publishers start. This audit proves + // local persisted compatibility, not that an old fleet has been fenced. + if config.nip_cl_enabled { + let checked = db + .verify_channel_metadata_activation(relay_keypair.public_key()) + .await?; + info!(checked, "NIP-CL canonical metadata activation audit passed"); + } + let partition_policy = buzz_db::partition::PartitionMaintenancePolicy { create_enabled: config.partition_manager_create_enabled, advance_enabled: config.partition_manager_advance_enabled, diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index d60771b2bc4..1f09c0fd453 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -381,6 +381,11 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st state.config.klipy.as_ref().map(|_| "klipy"), ); if let Ok(tenant) = crate::tenant::bind_community(&state.db, raw_host).await { + if state.config.nip_cl_enabled && relay_self.is_some() { + info.supported_extensions + .get_or_insert_default() + .push("nip-cl".to_owned()); + } if state.config.buzz_v1_enabled { use buzz_db::personal_read::{ELIGIBLE_KINDS, MAX_CHANNELS, MAX_INTENTS}; info.buzz_v1 = Some(BuzzV1Descriptor { diff --git a/docs/channel-labels-rollout.md b/docs/channel-labels-rollout.md new file mode 100644 index 00000000000..75fbd89f0d1 --- /dev/null +++ b/docs/channel-labels-rollout.md @@ -0,0 +1,114 @@ +# Channel labels: offline writer cutover + +NIP-CL is **off by default**. This release does not implement a distributed +binary-version fence. `BUZZ_NIP_CL_WRITER_CUTOVER=offline-v1` is an operator +attestation, not proof that old writers have stopped. Do not enable the feature +unless the procedure below is enforced by the deployment/database operator. + +## Boundary + +- `BUZZ_NIP_CL_ENABLED=true` enables labeled creation and label mutation and + advertises `nip-cl` on mapped community hosts. A configured + `BUZZ_RELAY_PRIVATE_KEY` and the exact cutover declaration are required. +- Startup checks every undeleted channel, in bounded pages across communities, + against the configured signer's complete canonical metadata projection. It + fails before listeners/background publishers if repair is needed. It neither + repairs nor establishes fleet exclusion. A community undergoing deletion can + fail admission during this audit; finish its lifecycle before activation. +- Ordinary publishers and full operator reconciliation preserve labels even + with the feature disabled. Unlabeled creation retains its legacy admission + when disabled; clients must not assume NIP-CL receipts without capability. +- All replicas and repair tools must use the same stable relay key. Key rotation, + import of draft `label`/extra-`t` formats, and arbitrary owner SQL are not + automatic migrations. Resolve those states explicitly before activation. + +## Cutover checklist + +1. Back up the database and relay key. Record source/image identities for every + command writer, metadata publisher, bootstrap/repair job, and maintenance + tool. Suspend autoscalers, restart policies and scheduled old repair jobs. +2. Close routing to **all** community hosts. Stop all old relay and operator + processes, drain transactions and prove they cannot restart with database + write access. Closing ingress alone does not stop background publishers. +3. Revoke the old deployment's database login (or replace its credential and + revoke all other write paths). Terminate its established sessions as well: + revocation does not terminate existing transactions. Use a distinct current + deployment credential. Verify old credentials cannot reconnect and + `pg_stat_activity` has no old writer sessions. If identities are shared, + fence the entire shared population. Record the observed exclusion, not just + a configuration change. Never resume an old binary with the new credential. +4. Apply migrations with the new operator binary (`buzz-admin migrate`) while + routing remains closed. Inventory community hosts from the operator control + plane. For **each** host run the new binary with the existing stable key: + + ```bash + # DATABASE_URL and BUZZ_RELAY_PRIVATE_KEY come from your secret manager. + RELAY_URL=wss://community.example buzz-admin reconcile-channels + ``` + + Full repair pages through all undeleted channels, including archived ones. + `--channel` is intentionally roster-only and does **not** repair metadata. + A successful run for one host is not evidence for another host. Resolve any + invalid stored label or oversized snapshot instead of clearing labels. +5. Start only compatible replicas, still behind closed routing, with: + + ```text + BUZZ_NIP_CL_ENABLED=true + BUZZ_NIP_CL_WRITER_CUTOVER=offline-v1 + BUZZ_RELAY_PRIVATE_KEY= + ``` + + Require the canonical metadata activation audit and readiness to pass on + each replica. A config parse pass is not readiness. Verify mapped-host + NIP-11 `self` equals the intended relay identity and `supported_extensions` + includes `nip-cl`; an unknown host must not advertise it. +6. Exercise creation, mutation, exact retry, label discovery and ordinary + metadata updates through each replica. Check signed snapshots and durable + application evidence. Recheck old-writer exclusion, then reopen routing. + +For a fresh isolated database, a zero-channel audit is valid. For an existing +fleet, an empty audit is not proof that the intended database was selected. +Record database identity and expected channel counts before activation. + +## Disable and rollback + +Set `BUZZ_NIP_CL_ENABLED=false` on every compatible replica to stop new label +commands and remove the advertisement. Keep the same key and label-preserving +metadata writers. A retained command with an uncertain outcome remains uncertain +if admission is now denied; preserve its exact signed journal. + +**Do not roll back to a pre-label binary while label state remains.** Such a +binary can erase metadata labels even if commands are disabled. Any rollback +requiring old code must keep routing closed and all old writers fenced pending a +separately reviewed state migration. Do not delete application evidence merely +to reapply a command. Soft deletion is not permission to replay it. + +## CLI recovery and human smoke test + +Use the built candidate CLI, not an older installed `buzz`. Over HTTPS the CLI +uses the authenticated NIP-11 `self`; over HTTP, supply `--trusted-relay ` +from an out-of-band source. Every new command needs a fresh local journal path; +parent directories must exist. Keep it until the outcome is resolved. + +```bash +buzz channels create --name label-smoke --type stream --visibility open \ + --label team:infra --command-file ./create.jsonl +# Save the returned channel_id as CHANNEL. +buzz channels labels get --channel "$CHANNEL" +buzz channels labels update --channel "$CHANNEL" --add-label status:active \ + --command-file ./add.jsonl +buzz channels labels find --label team:infra --limit 1 +buzz channels labels retry --command-file ./add.jsonl +buzz channels topic --channel "$CHANNEL" --topic ordinary-metadata +buzz channels labels get --channel "$CHANNEL" +``` + +Expected: command outcome `committed`; reads contain both labels after the topic +update. Retry reuses the original event and must not restore superseded labels. +`unknown` is not rejection. Use only `retry --command-file` for uncertain +commands; never generate a new ID or infer a receipt from current labels. +Committed journals return their known outcome without resending. Journals lock +out concurrent local attempts; do not copy a journal to send it concurrently. + +Agent evidence does not complete the repository's human-testing requirement. +Run this smoke test and report the candidate revision and observed results. diff --git a/migrations/0061_channel_labels.sql b/migrations/0061_channel_labels.sql new file mode 100644 index 00000000000..1f06fbc4709 --- /dev/null +++ b/migrations/0061_channel_labels.sql @@ -0,0 +1,13 @@ +-- NIP-CL state and atomic-application provenance. Supported application writers +-- validate canonical labels and serialize channel/metadata/evidence in one +-- admitted transaction. No new database function or trigger owns this protocol. +ALTER TABLE channels ADD COLUMN labels TEXT[] NOT NULL DEFAULT '{}'; +ALTER TABLE channels ADD CONSTRAINT chk_channel_labels_count + CHECK (cardinality(labels) <= 32); + +-- The command row is its receipt only when the NIP-CL transaction marks it. +-- Legacy stored requests remain unknown. Soft deletion preserves provenance; +-- retention must not remove fresh commands (including future-dated commands). +ALTER TABLE events ADD COLUMN nip_cl_applied BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE events ADD CONSTRAINT chk_events_nip_cl_applied_kind + CHECK (NOT nip_cl_applied OR kind IN (9002, 9007)); diff --git a/schema/tables/public/channels.sql b/schema/tables/public/channels.sql index 5fe5659cfb0..9b2783aeb66 100644 --- a/schema/tables/public/channels.sql +++ b/schema/tables/public/channels.sql @@ -32,6 +32,8 @@ CREATE TABLE channels ( participant_hash BYTEA, ttl_seconds INT, ttl_deadline TIMESTAMPTZ, + labels TEXT[] NOT NULL DEFAULT '{}', + CONSTRAINT chk_channel_labels_count CHECK (cardinality(labels) <= 32), PRIMARY KEY (community_id, id), CONSTRAINT chk_channels_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid) ); diff --git a/schema/tables/public/events.sql b/schema/tables/public/events.sql index 10c1adc19e9..6403f609f48 100644 --- a/schema/tables/public/events.sql +++ b/schema/tables/public/events.sql @@ -37,6 +37,8 @@ CREATE TABLE events ( d_tag TEXT, not_before BIGINT, delivered_at BIGINT, + nip_cl_applied BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT chk_events_nip_cl_applied_kind CHECK (NOT nip_cl_applied OR kind IN (9002, 9007)), PRIMARY KEY (community_id, created_at, id) ) PARTITION BY RANGE (created_at); diff --git a/scripts/channel-labels-e2e/README.md b/scripts/channel-labels-e2e/README.md new file mode 100644 index 00000000000..b335b4e13c3 --- /dev/null +++ b/scripts/channel-labels-e2e/README.md @@ -0,0 +1,61 @@ +# Native channel-label command E2E + +This runs the candidate CLI against **two separate candidate relay processes** +sharing isolated PostgreSQL and Redis. It uses the production HTTP bridge and +NIP-98 admission. A loopback proxy discards one successful response **after** +commit, records request hashes, then forwards the exact-event retry. This is not +a mocked receipt server. + +## Requirements and execution + +- An empty, disposable PostgreSQL database and disposable Redis service. Do not + point this harness at Desktop, staging, production, or another test's database. +- Built `buzz`, `buzz-relay`, and `buzz-admin` binaries from the same checkout. +- Python 3.10+ (Hermit `uv` can use an existing managed Python). No Python packages. +- Local loopback ports; seven free ports are selected. A bind race fails setup. + +```bash +. ./bin/activate-hermit +bin/cargo build -p buzz-cli -p buzz-relay -p buzz-admin +# Create a fresh DB using your isolated PostgreSQL administration connection. +# The harness migrates it; it never drops a database or changes DB credentials. +export LABEL_DATABASE_URL='postgres://user:password@127.0.0.1:5432/labels_unique_run' +export LABEL_REDIS_URL='redis://127.0.0.1:6379' +bin/uv run --no-project --offline python scripts/channel-labels-e2e/run.py --evidence /tmp/buzz-e2e/labels-unique-run --confirm-isolated-empty-database +``` + +Use `--bin-dir` for another Cargo target directory. The evidence directory must +not exist. It is mode 0700 and contains generated fixture private keys: **do not +publish it wholesale**. Share sanitized logs and public artifact hashes only. +The harness records source revision, dirty status and all three binary SHA-256s +in `SOURCE.json`. `FLOWS.stdout` lists assertions; `PROXY.jsonl` binds the lost ACK +and exact retry to identical command bodies and different transport proofs. +`TEARDOWN.json` records stopped processes; the database remains for inspection. +Remove the run-owned database and evidence manually when no longer needed. + +## Evidence boundaries + +Measured here: labeled creation, verified reads, committed-but-lost ACK, +opposing mutation, exact retry without reapplication, no-op snapshot reuse, +ordinary topic/purpose preservation (including empty labels), concurrent CLI +additions, generic label discovery, unauthorized rejection, both backend +processes exercised, and final readiness of both processes. + +The concurrent additions do **not** prove lock overlap. Deterministic lock +ordering, rollback, archive/deletion/removal races, current ACLs, cross-community +UUID/event collisions, stale discovery caches, mixed/id reads, activation, and +pagination are covered by the PostgreSQL/router suites instead. Run complete +packages with `scripts/postgres-test-run.sh -p buzz-db -p buzz-relay -p buzz-admin`; +see `crates/buzz-db/TESTING.md` for isolated runner configuration. Core and CLI +unit suites cover malformed grammar, snapshot trust and torn/exclusive journals. + +Test-only `BUZZ_GIT_CONFORMANCE_PROBE=false` avoids requiring S3 for this non-Git +workflow. Git/media admission, Docker relay images, Kubernetes, forced live +cross-process overlap, multi-tenant bystander progress, process-kill recovery, +and deployment credential fencing are **not established by this harness**. +The offline cutover declaration enables these isolated test processes; it does +not exercise a real fleet upgrade. See `docs/channel-labels-rollout.md`. + +The runner's readiness polling is bounded to 60 seconds; every command has a +bounded timeout and all run-owned processes are terminated on completion/failure. +Generated credentials never leave loopback test infrastructure. diff --git a/scripts/channel-labels-e2e/flows.py b/scripts/channel-labels-e2e/flows.py new file mode 100644 index 00000000000..a03565e3684 --- /dev/null +++ b/scripts/channel-labels-e2e/flows.py @@ -0,0 +1,86 @@ +"""Exercise the built CLI through two actual relay processes and a fault proxy.""" +import concurrent.futures +import json +import os +from pathlib import Path +import subprocess +import urllib.request + +ROOT = Path(os.environ["LABEL_EVIDENCE"]) +BINARY = Path(os.environ["LABEL_CLI"]) +CONFIG = json.loads((ROOT / "config.json").read_text()) +OWNER = json.loads((ROOT / "owner-key.json").read_text()) +RELAY = json.loads((ROOT / "relay-key.json").read_text()) +CHANNEL = json.loads((ROOT / "CREATE.stdout").read_text())["channel_id"] +ENV = {key: value for key, value in os.environ.items() if not key.startswith(("BUZZ_", "RELAY_"))} +ENV.update(BUZZ_RELAY_URL=CONFIG["url"], BUZZ_PRIVATE_KEY=OWNER["Secret key"]) +TRUST = ["--trusted-relay", RELAY["Public key"]] + + +def cli(name, args, expected=0, identity=None): + env = ENV.copy() + if identity: + env["BUZZ_PRIVATE_KEY"] = json.loads((ROOT / (identity + "-key.json")).read_text())["Secret key"] + result = subprocess.run([str(BINARY)] + args, env=env, capture_output=True, text=True, timeout=45) + (ROOT / (name + ".stdout")).write_text(result.stdout) + (ROOT / (name + ".stderr")).write_text(result.stderr) + assert result.returncode == expected, (name, result.returncode, result.stdout, result.stderr) + print("PASS", name, "exit", result.returncode, flush=True) + return json.loads(result.stdout) if result.stdout else None + + +def get(name): + return cli(name, ["channels", "labels", "get", "--channel", CHANNEL] + TRUST)[0] + + +def update(name, add=(), remove=(), expected=0, identity=None): + args = ["channels", "labels", "update", "--channel", CHANNEL, + "--command-file", str(ROOT / (name + ".jsonl"))] + TRUST + for value in add: + args += ["--add-label", value] + for value in remove: + args += ["--remove-label", value] + return cli(name, args, expected, identity) + + +assert get("initial")["labels"] == ["team:infra"] +(ROOT / "drop-next-ack").touch() +lost = update("lost-ack", add=["uncertain"], expected=2) +assert lost["outcome"] == "unknown" +assert "uncertain" in get("after-lost-ack")["labels"] +update("opposing", remove=["uncertain"]) +before = get("before-retry") +retry = cli("retry-lost", ["channels", "labels", "retry", "--command-file", str(ROOT / "lost-ack.jsonl")]) +assert retry["event_id"] == lost["event_id"] and retry["outcome"] == "committed" +after = get("after-retry") +assert after["event"]["id"] == before["event"]["id"] and after["labels"] == ["team:infra"] +update("noop", add=["team:infra"]) +assert get("after-noop")["event"]["id"] == after["event"]["id"] +cli("ordinary", ["channels", "topic", "--channel", CHANNEL, "--topic", "ordinary metadata preserves labels"]) +assert get("after-ordinary")["labels"] == ["team:infra"] +with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + list(executor.map(lambda name: update(name, add=[name]), ["parallel-a", "parallel-b"])) +assert get("after-parallel")["labels"] == ["parallel-a", "parallel-b", "team:infra"] +found = cli("find", ["channels", "labels", "find", "--label", "team:infra", "--limit", "1"] + TRUST) +assert found[0]["channel_id"] == CHANNEL +update("denied", add=["forbidden"], expected=4, identity="outsider") +assert "forbidden" not in get("after-denied")["labels"] +update("remove-all", remove=["parallel-a", "parallel-b", "team:infra"]) +empty = get("empty") +assert empty["labels"] == [] and all(tag[0] not in ["l", "L"] for tag in empty["event"]["tags"]) +cli("ordinary-empty", ["channels", "purpose", "--channel", CHANNEL, "--purpose", "empty set remains empty"]) +assert get("after-empty-publisher")["labels"] == [] +# The proxy must have withheld a committed reply, not failed before sending. +entries = [json.loads(line) for line in (ROOT / "PROXY.jsonl").read_text().splitlines()] +submissions = [entry for entry in entries if entry["event"] == lost["event_id"]] +assert len(submissions) == 2, submissions +assert submissions[0]["dropped"] and submissions[0]["ack"]["accepted"] +assert submissions[1]["ack"]["message"] == "duplicate: nip-cl-committed" +assert submissions[0]["body_sha256"] == submissions[1]["body_sha256"] +assert submissions[0]["auth_sha256"] != submissions[1]["auth_sha256"] +assert {entry["backend"] for entry in entries if entry["path"] == "/events"} == set(CONFIG["backends"]) +print("PASS exact signed command recovered; acknowledgement lost after commit; both relay processes exercised", flush=True) +for port in CONFIG["health"]: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/_readiness", timeout=5) as response: + assert json.load(response)["status"] == "ready" +print("PASS both replicas ready after workflows", flush=True) diff --git a/scripts/channel-labels-e2e/proxy.py b/scripts/channel-labels-e2e/proxy.py new file mode 100644 index 00000000000..65d16439662 --- /dev/null +++ b/scripts/channel-labels-e2e/proxy.py @@ -0,0 +1,81 @@ +"""Loopback-only fault proxy: lose a committed ACK without changing the command.""" +import hashlib +import http.client +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import os +from pathlib import Path +import threading + +ROOT = Path(os.environ["LABEL_EVIDENCE"]) +CONFIG = json.loads((ROOT / "config.json").read_text()) +LOCK = threading.Lock() +SEQUENCE = 0 + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_GET(self): + self.forward() + + def do_POST(self): + self.forward() + + def forward(self): + global SEQUENCE + length = int(self.headers.get("content-length", "0")) + if length > 1024 * 1024: + self.send_error(413) + return + body = self.rfile.read(length) + with LOCK: + SEQUENCE += 1 + number = SEQUENCE + backend = CONFIG["backends"][number % 2] + drop = self.path == "/events" and (ROOT / "drop-next-ack").exists() + if drop: + (ROOT / "drop-next-ack").unlink() + event = json.loads(body) if self.path == "/events" else {} + entry = {"sequence": number, "backend": backend, "path": self.path, + "event": event.get("id"), "body_sha256": hashlib.sha256(body).hexdigest(), + "auth_sha256": hashlib.sha256(self.headers.get("authorization", "").encode()).hexdigest()} + conn = http.client.HTTPConnection("127.0.0.1", backend, timeout=30) + try: + headers = {key: value for key, value in self.headers.items() + if key.lower() not in ("content-length", "connection", "accept-encoding")} + conn.request(self.command, self.path, body, headers) + response = conn.getresponse() + data = response.read(2 * 1024 * 1024 + 1) + if len(data) > 2 * 1024 * 1024: + raise ValueError("response exceeds fixture budget") + entry["status"] = response.status + if self.path == "/events": + entry["ack"] = json.loads(data) + entry["dropped"] = drop + with LOCK: + with (ROOT / "PROXY.jsonl").open("a") as log: + log.write(json.dumps(entry) + "\n") + if drop: + self.close_connection = True + self.connection.shutdown(2) + self.connection.close() + return + self.send_response(response.status) + for key, value in response.getheaders(): + if key.lower() not in ("transfer-encoding", "content-length", "connection", "content-encoding"): + self.send_header(key, value) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + except Exception as error: + with LOCK: + with (ROOT / "PROXY_ERRORS.log").open("a") as log: + log.write(str(error) + "\n") + self.close_connection = True + finally: + conn.close() + + +ThreadingHTTPServer(("127.0.0.1", CONFIG["proxy"]), Handler).serve_forever() diff --git a/scripts/channel-labels-e2e/run.py b/scripts/channel-labels-e2e/run.py new file mode 100644 index 00000000000..10861cfc354 --- /dev/null +++ b/scripts/channel-labels-e2e/run.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Native two-replica NIP-CL smoke test; requires an EMPTY isolated database.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request + +REPO = Path(__file__).resolve().parents[2] + + +def ports(count): + sockets = [] + try: + for _ in range(count): + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + sockets.append(sock) + return [sock.getsockname()[1] for sock in sockets] + finally: + for sock in sockets: + sock.close() + + +def sha256(path): + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--bin-dir", type=Path, default=REPO / "target/debug") + parser.add_argument("--confirm-isolated-empty-database", action="store_true", required=True) + args = parser.parse_args() + root = args.evidence.resolve() + root.mkdir(mode=0o700, parents=True, exist_ok=False) + binary = args.bin_dir.resolve() + # Refuse a missing destination rather than fall back to a developer database. + database = os.environ["LABEL_DATABASE_URL"] + redis = os.environ["LABEL_REDIS_URL"] + env = {key: value for key, value in os.environ.items() if not key.startswith( + ("BUZZ_", "RELAY_", "DATABASE_", "REDIS_", "READ_DATABASE_", "OTEL_"))} + env.update(DATABASE_URL=database, REDIS_URL=redis) + revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=REPO, text=True).strip() + status = subprocess.check_output(["git", "status", "--short"], cwd=REPO, text=True) + identity = {"revision": revision, "status": status, "python": sys.version, + "artifacts": {name: sha256(binary / name) for name in ("buzz", "buzz-relay", "buzz-admin")}} + (root / "SOURCE.json").write_text(json.dumps(identity, indent=2)) + + def run(name, command, command_env=env): + with (root / (name + ".stdout")).open("wb") as out, (root / (name + ".stderr")).open("wb") as err: + result = subprocess.run(command, env=command_env, cwd=root, stdout=out, stderr=err, timeout=120) + if result.returncode: + raise RuntimeError(f"{name}: exit {result.returncode}; inspect evidence") + + keys = {} + for name in ("owner", "relay", "outsider"): + raw = subprocess.check_output([str(binary / "buzz-admin"), "generate-key"], env=env, text=True) + keys[name] = dict(line.split(":", 1) for line in raw.splitlines() if ":" in line) + keys[name] = {key.strip(): value.strip() for key, value in keys[name].items()} + (root / (name + "-key.json")).write_text(json.dumps(keys[name])) + chosen = ports(7) + config = {"proxy": chosen[0], "backends": chosen[1:3], "health": chosen[3:5], "metrics": chosen[5:7], + "url": f"http://127.0.0.1:{chosen[0]}"} + (root / "config.json").write_text(json.dumps(config)) + env.update(BUZZ_RELAY_PRIVATE_KEY=keys["relay"]["Secret key"], + RELAY_OWNER_PUBKEY=keys["owner"]["Public key"], RELAY_URL=config["url"], + BUZZ_NIP_CL_ENABLED="true", BUZZ_NIP_CL_WRITER_CUTOVER="offline-v1", + BUZZ_REQUIRE_AUTH_TOKEN="false", BUZZ_REQUIRE_RELAY_MEMBERSHIP="false", + BUZZ_AUTO_MIGRATE="false", BUZZ_MESH="false", BUZZ_GIT_CONFORMANCE_PROBE="false", + BUZZ_PARTITION_MANAGER_CREATE_ENABLED="false", RUST_LOG="info", + LABEL_EVIDENCE=str(root), LABEL_CLI=str(binary / "buzz")) + run("MIGRATE", [str(binary / "buzz-admin"), "migrate"]) + children = [] + logs = [] + try: + for index in range(2): + replica_env = dict(env, BUZZ_BIND_ADDR=f"127.0.0.1:{config['backends'][index]}", + BUZZ_HEALTH_PORT=str(config["health"][index]), + BUZZ_METRICS_PORT=str(config["metrics"][index]), + BUZZ_GIT_REPO_PATH=str(root / f"git-{index}"), + BUZZ_GIT_PACK_CACHE_PATH=str(root / f"pack-{index}")) + log = (root / f"RELAY_{index}.log").open("wb") + logs.append(log) + children.append(subprocess.Popen([str(binary / "buzz-relay")], env=replica_env, + cwd=root, stdout=log, stderr=subprocess.STDOUT)) + proxy_log = (root / "PROXY_SERVER.log").open("wb") + logs.append(proxy_log) + children.append(subprocess.Popen([sys.executable, str(Path(__file__).with_name("proxy.py"))], + env=env, cwd=root, stdout=proxy_log, stderr=subprocess.STDOUT)) + deadline = time.monotonic() + 60 + while True: + if any(child.poll() is not None for child in children): + raise RuntimeError("test process exited during readiness; inspect evidence") + try: + for port in config["health"]: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/_readiness", timeout=2) as response: + assert json.load(response)["status"] == "ready" + request = urllib.request.Request(config["url"], headers={"Accept": "application/nostr+json"}) + with urllib.request.urlopen(request, timeout=2) as response: + info = json.load(response) + assert "nip-cl" in info["supported_extensions"] + assert info["self"] == keys["relay"]["Public key"] + (root / "NIP11.json").write_text(json.dumps(info)) + break + except (OSError, urllib.error.URLError, AssertionError): + if time.monotonic() >= deadline: + raise RuntimeError("readiness/capability deadline exceeded") + time.sleep(0.1) + cli_env = dict(env, BUZZ_RELAY_URL=config["url"], BUZZ_PRIVATE_KEY=keys["owner"]["Secret key"]) + run("CREATE", [str(binary / "buzz"), "channels", "create", "--name", "labels-live", + "--type", "stream", "--visibility", "open", "--label", "team:infra", + "--command-file", str(root / "create.jsonl"), "--trusted-relay", keys["relay"]["Public key"]], cli_env) + run("FLOWS", [sys.executable, str(Path(__file__).with_name("flows.py"))]) + print((root / "FLOWS.stdout").read_text()) + print(f"PASS evidence: {root}; database retained for inspection") + finally: + for child in children: + if child.poll() is None: + child.terminate() + for child in children: + try: + child.wait(timeout=10) + except subprocess.TimeoutExpired: + child.kill() + child.wait(timeout=10) + for log in logs: + log.close() + (root / "TEARDOWN.json").write_text(json.dumps({"processes": [child.returncode for child in children], + "database_retained": True})) + + +if __name__ == "__main__": + main() From 8dce9617ec6a55e367326e885110eec149cff37e Mon Sep 17 00:00:00 2001 From: cyberpunk Date: Fri, 9 Oct 2026 20:34:51 -0700 Subject: [PATCH 4/8] fix: separate label integrity from ordinary metadata freshness Signed-off-by: cyberpunk --- crates/buzz-admin/src/channel_metadata.rs | 149 ++++++++++++++++-- crates/buzz-admin/src/main.rs | 87 ++-------- .../tests/postgres_channel_repair.rs | 145 +++++++++++++++++ crates/buzz-core/src/relay.rs | 25 ++- .../src/store/channel_metadata/activation.rs | 18 ++- .../src/store/channel_metadata/command.rs | 5 +- .../activation_postgres_tests.rs | 27 +++- .../src/store/channel_metadata/snapshot.rs | 22 ++- crates/buzz-relay/src/audio/handler.rs | 34 +++- crates/buzz-relay/src/config.rs | 8 +- crates/buzz-relay/src/main.rs | 2 +- crates/buzz-relay/tests/boot_lifecycle.rs | 85 ++++++++++ docs/channel-labels-rollout.md | 26 ++- 13 files changed, 511 insertions(+), 122 deletions(-) create mode 100644 crates/buzz-admin/tests/postgres_channel_repair.rs diff --git a/crates/buzz-admin/src/channel_metadata.rs b/crates/buzz-admin/src/channel_metadata.rs index 2f3d62421f1..cc8d1699e50 100644 --- a/crates/buzz-admin/src/channel_metadata.rs +++ b/crates/buzz-admin/src/channel_metadata.rs @@ -1,9 +1,12 @@ //! Operator signing for application-owned canonical metadata repair. -use anyhow::{Context, Result}; -use buzz_core::{kind::KIND_NIP29_GROUP_METADATA, TenantContext}; +use anyhow::Result; +use buzz_core::{ + kind::{KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA}, + TenantContext, +}; use buzz_db::Db; -use nostr::{EventBuilder, Keys, Kind}; +use nostr::{EventBuilder, Keys, Kind, Tag}; use uuid::Uuid; pub(crate) async fn repair( @@ -30,14 +33,140 @@ pub(crate) async fn repair( .sign_with_keys(&keys) }) .await??; - let max_bytes = match std::env::var("BUZZ_MAX_FRAME_BYTES") { - Ok(value) => value - .parse::() - .context("invalid BUZZ_MAX_FRAME_BYTES")?, - Err(std::env::VarError::NotPresent) => 512 * 1024, - Err(error) => return Err(error.into()), - }; + let max_bytes = buzz_core::relay::max_frame_bytes_from_env(); write.store_snapshot(&event, max_bytes).await?; write.commit().await?; Ok(true) } + +#[derive(Default)] +pub(crate) struct RepairSummary { + pub repaired: u64, + pub skipped: u64, + pub failed: u64, +} + +pub(crate) async fn reconcile( + db: &Db, + tenant: &TenantContext, + target: Option, + keys: &Keys, +) -> Result { + let mut cursor = Uuid::nil(); + let mut summary = RepairSummary::default(); + loop { + let channels = if let Some(channel) = target { + vec![channel] + } else { + db.channel_metadata_repair_page(tenant.community(), cursor) + .await? + }; + if channels.is_empty() { + break; + } + for channel in channels { + cursor = channel; + match reconcile_one(db, tenant, channel, keys, target.is_some()).await { + Ok(true) => summary.repaired += 1, + Ok(false) => summary.skipped += 1, + Err(error) => { + summary.failed += 1; + eprintln!("channel {channel}: reconciliation failed: {error:#}"); + } + } + } + if target.is_some() { + break; + } + } + Ok(summary) +} + +async fn reconcile_one( + db: &Db, + tenant: &TenantContext, + channel: Uuid, + keys: &Keys, + roster_only: bool, +) -> Result { + // Keep targeted reconciliation's contract: only refresh kind 39002. + db.get_channel(tenant.community(), channel).await?; + let repaired = if roster_only { + false + } else { + repair(db, tenant, channel, keys).await? + }; + // Auxiliary snapshots commit independently of metadata. A current 39000 is + // not evidence that a previous bootstrap finished publishing 39001/39002. + let discovery = if roster_only { + Vec::new() + } else { + db.query_events_for_bootstrap(&buzz_db::event::EventQuery { + kinds: Some(vec![ + KIND_NIP29_GROUP_ADMINS as i32, + KIND_NIP29_GROUP_MEMBERS as i32, + ]), + authors: Some(vec![keys.public_key().to_bytes().to_vec()]), + d_tag: Some(channel.to_string()), + ..buzz_db::event::EventQuery::for_community(tenant.community()) + }) + .await? + }; + let missing = |kind| { + !discovery + .iter() + .any(|stored| buzz_core::kind::event_kind_u32(&stored.event) == kind) + }; + let admins = !roster_only && missing(KIND_NIP29_GROUP_ADMINS); + let members = roster_only || missing(KIND_NIP29_GROUP_MEMBERS); + if !admins && !members { + return Ok(repaired); + } + let roster = db.get_members(tenant.community(), channel).await?; + for kind in [KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS] { + if (kind == KIND_NIP29_GROUP_ADMINS && !admins) + || (kind == KIND_NIP29_GROUP_MEMBERS && !members) + { + continue; + } + let mut tags = vec![Tag::parse(["d", &channel.to_string()])?]; + for member in &roster { + let pk = hex::encode(&member.pubkey); + if kind == KIND_NIP29_GROUP_ADMINS { + if member.role == "owner" || member.role == "admin" { + tags.push(Tag::parse(["p", &pk, &member.role])?); + } + } else { + tags.push(Tag::parse(["p", &pk, "", &member.role])?); + } + } + // Include retired rows: signing the same tags in the same second as a + // deleted head would reproduce its ID and silently lose ON CONFLICT. + let previous: Option = sqlx::query_scalar( + "SELECT EXTRACT(EPOCH FROM max(created_at))::bigint FROM events WHERE community_id=$1 AND channel_id=$2 AND kind=$3 AND pubkey=$4", + ).bind(tenant.community().as_uuid()).bind(channel).bind(kind as i32) + .bind(keys.public_key().as_bytes().as_slice()).fetch_one(db.pool()).await?; + let next = previous + .map(|ts| u64::try_from(ts).ok().and_then(|ts| ts.checked_add(1))) + .unwrap_or(Some(0)) + .ok_or_else(|| anyhow::anyhow!("discovery timestamp overflow"))?; + let timestamp = nostr::Timestamp::from(next.max(nostr::Timestamp::now().as_secs())); + let keys = keys.clone(); + let event = tokio::task::spawn_blocking(move || { + EventBuilder::new(Kind::Custom(kind as u16), "") + .tags(tags) + .allow_self_tagging() + .custom_created_at(timestamp) + .sign_with_keys(&keys) + }) + .await??; + let (_, inserted) = db + .replace_addressable_event(tenant.community(), &event, Some(channel)) + .await?; + anyhow::ensure!( + inserted, + "discovery publication superseded; rerun reconciliation" + ); + } + Ok(true) +} diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index d399ecff752..172f00ccfc4 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -739,8 +739,6 @@ async fn reconcile_channels( channel_arg: Option, relay_key_arg: Option, ) -> Result<()> { - use buzz_core::kind::KIND_NIP29_GROUP_ADMINS; - let db = connect_db().await?; // Repair is authoritative publication. An ephemeral signer would create @@ -759,81 +757,18 @@ async fn reconcile_channels( .map(uuid::Uuid::parse_str) .transpose() .map_err(|e| anyhow::anyhow!("invalid --channel UUID: {e}"))?; - let mut cursor = uuid::Uuid::nil(); - let mut reconciled = 0u64; - let mut skipped = 0u64; - loop { - let channels = if let Some(target) = target_channel { - // Targeted reconciliation remains roster-only, with the existing - // live-channel admission check. - db.get_channel(tenant.community(), target).await?; - vec![target] - } else { - db.channel_metadata_repair_page(tenant.community(), cursor) - .await? - }; - if channels.is_empty() { - break; - } - for channel in channels { - cursor = channel; - let channel_id_str = channel.to_string(); - - if target_channel.is_none() - && !channel_metadata::repair(&db, &tenant, channel, &relay_keys).await? - { - skipped += 1; - continue; - } - let members = db.get_members(tenant.community(), channel).await?; - - // Targeted repair remains roster-only. Full repair compares the complete - // projection through the same database boundary as the relay publisher. - if target_channel.is_none() { - // kind:39001 — admins - { - let mut tags: Vec = vec![Tag::parse(["d", &channel_id_str])?]; - for m in members - .iter() - .filter(|m| m.role == "owner" || m.role == "admin") - { - let pk = hex::encode(&m.pubkey); - tags.push(Tag::parse(["p", &pk, &m.role])?); - } - let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_ADMINS as u16), "") - .tags(tags) - .sign_with_keys(&relay_keys) - .map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?; - db.replace_addressable_event(tenant.community(), &event, Some(channel)) - .await?; - } - } - - // kind:39002 — members - { - let mut tags: Vec = vec![Tag::parse(["d", &channel_id_str])?]; - for m in &members { - let pk = hex::encode(&m.pubkey); - tags.push(Tag::parse(["p", &pk, "", &m.role])?); - } - let event = EventBuilder::new(Kind::Custom(39002), "") - .tags(tags) - .sign_with_keys(&relay_keys) - .map_err(|e| anyhow::anyhow!("sign kind:39002: {e}"))?; - db.replace_addressable_event(tenant.community(), &event, Some(channel)) - .await?; - } - - reconciled += 1; - } - if target_channel.is_some() { - break; - } - } - + let summary = channel_metadata::reconcile(&db, &tenant, target_channel, &relay_keys).await?; println!( - "Reconciled {reconciled} channels ({skipped} already current, {} total).", - reconciled + skipped + "Reconciled {} channels ({} already current, {} failed, {} total).", + summary.repaired, + summary.skipped, + summary.failed, + summary.repaired + summary.skipped + summary.failed + ); + anyhow::ensure!( + summary.failed == 0, + "channel reconciliation incomplete: {} failed", + summary.failed ); Ok(()) } diff --git a/crates/buzz-admin/tests/postgres_channel_repair.rs b/crates/buzz-admin/tests/postgres_channel_repair.rs new file mode 100644 index 00000000000..35224ec6c89 --- /dev/null +++ b/crates/buzz-admin/tests/postgres_channel_repair.rs @@ -0,0 +1,145 @@ +//! Exercise the real operator command, including aggregate failure status. +use buzz_core::{ + channel::{ChannelType, ChannelVisibility}, + TenantContext, +}; +use buzz_db::{Db, DbConfig}; +use nostr::{EventBuilder, Keys, Kind}; +use uuid::Uuid; + +async fn fixture() -> (Db, TenantContext, Keys) { + let db = Db::new(&DbConfig { + database_url: std::env::var("BUZZ_TEST_DATABASE_URL") + .expect("isolated postgres-ci database"), + ..DbConfig::default() + }) + .await + .unwrap(); + let host = format!("repair-{}.example", Uuid::new_v4()); + let community = db.ensure_configured_community(&host).await.unwrap().id; + ( + db, + TenantContext::resolved(community, host), + Keys::generate(), + ) +} + +async fn create(db: &Db, tenant: &TenantContext, id: Uuid, keys: &Keys) { + db.create_channel_with_id( + tenant.community(), + id, + "repair", + ChannelType::Stream, + ChannelVisibility::Open, + None, + keys.public_key().as_bytes(), + None, + ) + .await + .unwrap(); +} + +async fn run(tenant: &TenantContext, keys: &Keys, channel: Option) -> std::process::Output { + let binary = std::env::var_os("NEXTEST_BIN_EXE_buzz_admin") + .unwrap_or_else(|| env!("CARGO_BIN_EXE_buzz-admin").into()); + let mut command = tokio::process::Command::new(binary); + command + .arg("reconcile-channels") + .env_clear() + .env( + "DATABASE_URL", + std::env::var("BUZZ_TEST_DATABASE_URL").unwrap(), + ) + .env("RELAY_URL", format!("wss://{}", tenant.host())) + .env("BUZZ_RELAY_PRIVATE_KEY", keys.secret_key().to_secret_hex()) + .kill_on_drop(true); + if let Some(id) = channel { + command.args(["--channel", &id.to_string()]); + } + tokio::time::timeout(std::time::Duration::from_secs(30), command.output()) + .await + .unwrap() + .unwrap() +} + +async fn discovery(db: &Db, tenant: &TenantContext, channel: Uuid) -> Vec<(i32, Vec)> { + sqlx::query_as("SELECT kind, id FROM events WHERE community_id=$1 AND channel_id=$2 AND kind IN (39000,39001,39002) AND deleted_at IS NULL ORDER BY kind") + .bind(tenant.community().as_uuid()).bind(channel).fetch_all(db.pool()).await.unwrap() +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn current_metadata_does_not_skip_missing_auxiliary_discovery() { + let (db, tenant, keys) = fixture().await; + let channel = Uuid::new_v4(); + create(&db, &tenant, channel, &keys).await; + let mut write = db + .begin_channel_metadata_write(tenant.community(), channel, keys.public_key()) + .await + .unwrap(); + let event = EventBuilder::new(Kind::Custom(39000), "") + .tags(write.snapshot_tags().await.unwrap()) + .custom_created_at(write.snapshot_timestamp().unwrap()) + .sign_with_keys(&keys) + .unwrap(); + write.store_snapshot(&event, 512 * 1024).await.unwrap(); + write.commit().await.unwrap(); + // Both missing, then each missing independently. Preserve the current head. + for missing in [None, Some(39001), Some(39002)] { + if let Some(kind) = missing { + // Include retired heads, even when repair happens in the same second. + sqlx::query("UPDATE events SET deleted_at=NOW() WHERE community_id=$1 AND channel_id=$2 AND kind=$3") + .bind(tenant.community().as_uuid()) + .bind(channel) + .bind(kind) + .execute(db.pool()) + .await + .unwrap(); + } + let output = run(&tenant, &keys, None).await; + assert!(output.status.success(), "{:?}", output); + let rows = discovery(&db, &tenant, channel).await; + assert_eq!( + rows.iter().map(|r| r.0).collect::>(), + [39000, 39001, 39002] + ); + assert_eq!(rows[0].1, event.id.as_bytes()); + } + let before = discovery(&db, &tenant, channel).await; + let output = run(&tenant, &keys, Some(channel)).await; + assert!(output.status.success(), "{:?}", output); + let after = discovery(&db, &tenant, channel).await; + assert_eq!(&before[..2], &after[..2], "targeted repair is roster-only"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn repair_finishes_scan_but_exits_unsuccessfully_after_a_channel_error() { + let (db, tenant, keys) = fixture().await; + let broken = Uuid::from_u128(1); + let healthy = Uuid::from_u128(2); + for id in [broken, healthy] { + create(&db, &tenant, id, &keys).await; + } + sqlx::query( + "UPDATE channels SET labels=ARRAY['INVALID']::text[] WHERE community_id=$1 AND id=$2", + ) + .bind(tenant.community().as_uuid()) + .bind(broken) + .execute(db.pool()) + .await + .unwrap(); + let output = run(&tenant, &keys, None).await; + assert!(!output.status.success(), "must report incomplete repair"); + assert!( + String::from_utf8_lossy(&output.stdout).contains("1 failed"), + "{:?}", + output + ); + assert_eq!( + discovery(&db, &tenant, healthy).await.len(), + 3, + "later channels still repaired" + ); + assert!(discovery(&db, &tenant, broken).await.is_empty()); +} diff --git a/crates/buzz-core/src/relay.rs b/crates/buzz-core/src/relay.rs index 77c74a069ad..d12f1ad878c 100644 --- a/crates/buzz-core/src/relay.rs +++ b/crates/buzz-core/src/relay.rs @@ -1,8 +1,23 @@ -//! Canonical relay identities shared by runtime components. +//! Relay identity and transport settings shared by runtime components. use thiserror::Error; use url::{Host, Url}; +/// Default inbound event/frame limit, shared by relay and operator publishers. +pub const DEFAULT_MAX_FRAME_BYTES: usize = 512 * 1024; + +/// Resolve the transport limit with the relay's positive-integer fallback policy. +pub fn max_frame_bytes_from_env() -> usize { + parse_max_frame_bytes(std::env::var("BUZZ_MAX_FRAME_BYTES").ok().as_deref()) +} + +fn parse_max_frame_bytes(value: Option<&str>) -> usize { + value + .and_then(|v| v.parse::().ok()) + .filter(|&v| v > 0) + .unwrap_or(DEFAULT_MAX_FRAME_BYTES) +} + /// Errors returned while canonicalizing a relay URL for runtime identity. #[derive(Debug, Error, PartialEq, Eq)] pub enum NormalizeRelayUrlError { @@ -81,6 +96,14 @@ pub fn normalize_relay_url(raw: &str) -> Result mod tests { use super::*; + #[test] + fn frame_limit_accepts_only_positive_integers() { + for value in [None, Some(""), Some("0"), Some("bad"), Some("-1")] { + assert_eq!(parse_max_frame_bytes(value), DEFAULT_MAX_FRAME_BYTES); + } + assert_eq!(parse_max_frame_bytes(Some("262144")), 262144); + } + #[test] fn loopback_spellings_have_one_identity() { let ipv6 = normalize_relay_url("wss://[::1]/").unwrap(); diff --git a/crates/buzz-db/src/store/channel_metadata/activation.rs b/crates/buzz-db/src/store/channel_metadata/activation.rs index f807a3bbce9..61e2bf34477 100644 --- a/crates/buzz-db/src/store/channel_metadata/activation.rs +++ b/crates/buzz-db/src/store/channel_metadata/activation.rs @@ -31,7 +31,13 @@ impl Db { .await?) } - /// Verify that every live channel has the canonical current relay projection. + /// Verify label integrity without requiring freshness of ordinary metadata. + /// + /// Existing heads must contain the trusted canonical current label set. A + /// missing head is allowed only for an unlabeled channel: ordinary creation + /// can commit before its separate publisher, even with label commands off. + /// Topic/archive changes also commit before publication and cannot be a + /// routine restart prerequisite. Full operator repair still checks all tags. /// /// Operator-only, cross-community startup audit. Call before serving when /// enabling NIP-CL, after old writers have been externally fenced and repair @@ -57,10 +63,16 @@ impl Db { } for (community, channel) in channels { cursor = (community, channel); - let mut write = self + let write = self .begin_channel_metadata_write(CommunityId::from_uuid(community), channel, relay) .await?; - let stale = write.needs_snapshot().await?; + let stale = if write.previous.is_none() && write.labels().values().is_empty() { + // Still reject a channel deleted after enumeration. + write.channel()?; + false + } else { + !write.has_current_label_snapshot().await? + }; write.rollback().await?; if stale { return Err(DbError::InvalidData(format!( diff --git a/crates/buzz-db/src/store/channel_metadata/command.rs b/crates/buzz-db/src/store/channel_metadata/command.rs index 33fcdd19330..d9ea5399479 100644 --- a/crates/buzz-db/src/store/channel_metadata/command.rs +++ b/crates/buzz-db/src/store/channel_metadata/command.rs @@ -38,8 +38,9 @@ pub enum CommandApplication { impl ChannelMetadataWrite { async fn has_label_authority(&mut self, actor: &[u8]) -> Result { - // Membership writers use the lock held by this guard. Ownership is - // write-once in user::set_agent_owner; this query checks every owner. + // Ordinary membership writers share this guard's lock. Administrative + // kicks need not: an overlapping command may serialize before the kick. + // Ownership is write-once in user::set_agent_owner; check every owner. Ok(sqlx::query_scalar( "SELECT EXISTS (SELECT 1 FROM channel_members cm \ LEFT JOIN users u ON u.community_id = cm.community_id AND u.pubkey = cm.pubkey \ diff --git a/crates/buzz-db/src/store/channel_metadata/postgres_tests/activation_postgres_tests.rs b/crates/buzz-db/src/store/channel_metadata/postgres_tests/activation_postgres_tests.rs index 1d5ffa9cbb1..2c24dae85b1 100644 --- a/crates/buzz-db/src/store/channel_metadata/postgres_tests/activation_postgres_tests.rs +++ b/crates/buzz-db/src/store/channel_metadata/postgres_tests/activation_postgres_tests.rs @@ -2,7 +2,7 @@ use super::*; #[tokio::test] #[ignore = "requires Postgres"] -async fn activation_checks_current_content_and_all_communities_without_mutation() { +async fn activation_checks_labels_but_tolerates_unpublished_ordinary_metadata() { let (db, community) = database(false).await; let relay = Keys::generate(); let owner = Keys::generate(); @@ -27,14 +27,19 @@ async fn activation_checks_current_content_and_all_communities_without_mutation( ) .await .unwrap(); - assert!(db - .verify_channel_metadata_activation(relay.public_key()) - .await - .is_err()); + db.archive_channel(community, channel).await.unwrap(); + assert_eq!( + db.verify_channel_metadata_activation(relay.public_key()) + .await + .unwrap(), + 129, + "unpublished topic/archive changes must not prevent routine restart" + ); let mut write = db .begin_channel_metadata_write(community, channel, relay.public_key()) .await .unwrap(); + assert!(write.needs_snapshot().await.unwrap()); snapshot(&mut write, &relay).await; write.commit().await.unwrap(); assert_eq!( @@ -72,9 +77,10 @@ async fn activation_checks_current_content_and_all_communities_without_mutation( .await .unwrap() .id; + let missing = Uuid::new_v4(); db.create_channel_with_id( other, - Uuid::new_v4(), + missing, "missing", ChannelType::Stream, ChannelVisibility::Open, @@ -84,6 +90,15 @@ async fn activation_checks_current_content_and_all_communities_without_mutation( ) .await .unwrap(); + assert_eq!( + db.verify_channel_metadata_activation(relay.public_key()) + .await + .unwrap(), + 130 + ); + // Missing unlabeled publication is recoverable; missing labeled state is not. + sqlx::query("UPDATE channels SET labels = ARRAY['retained']::text[] WHERE community_id = $1 AND id = $2") + .bind(other.as_uuid()).bind(missing).execute(db.pool()).await.unwrap(); assert!(db .verify_channel_metadata_activation(relay.public_key()) .await diff --git a/crates/buzz-db/src/store/channel_metadata/snapshot.rs b/crates/buzz-db/src/store/channel_metadata/snapshot.rs index 63045d807c6..367b65e90e0 100644 --- a/crates/buzz-db/src/store/channel_metadata/snapshot.rs +++ b/crates/buzz-db/src/store/channel_metadata/snapshot.rs @@ -79,12 +79,7 @@ impl ChannelMetadataWrite { let Some(previous) = self.previous.as_ref() else { return Ok(true); }; - if verify_snapshot_async(&previous.event, self.relay, self.channel_id) - .await? - .ok() - .as_ref() - != Some(&self.labels) - { + if !self.has_current_label_snapshot().await? { return Ok(true); } // The existing TTL protocol refreshes deadlines on event commits. It is @@ -103,6 +98,21 @@ impl ChannelMetadataWrite { || comparable(tags) != comparable(previous.event.tags.iter().cloned().collect())) } + /// Check only the atomic label projection, not commit-then-publish metadata. + pub(super) async fn has_current_label_snapshot(&self) -> Result { + self.channel()?; + let Some(previous) = self.previous.as_ref() else { + return Ok(false); + }; + Ok( + verify_snapshot_async(&previous.event, self.relay, self.channel_id) + .await? + .ok() + .as_ref() + == Some(&self.labels), + ) + } + /// Store a freshly signed snapshot on this transaction after validating its projection. /// /// The caller signs `snapshot_tags()` with `snapshot_timestamp()`. No network diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 8115ed9bbd4..b3a7afcdbc0 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -2109,6 +2109,13 @@ pub(crate) async fn handle_active_audio_connection( room_emptied = false; } Ok(()) => { + if let Err(error) = + crate::handlers::channel_metadata::publish_metadata(&tenant, &state, channel_id) + .await + { + warn!(channel_id = %channel_id, %error, + "auto-archive metadata publication failed; operator repair required"); + } room_emptied = state .audio_rooms .cleanup_if_empty(tenant.community(), channel_id); @@ -9728,6 +9735,8 @@ mod tests { ); let audio_rooms = Arc::clone(&state.audio_rooms); + let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>(); + let done_tx = Arc::new(std::sync::Mutex::new(Some(done_tx))); let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); let conn_cancel = CancellationToken::new(); let state_c = Arc::clone(&state); @@ -9754,6 +9763,7 @@ mod tests { let assertion_i = assertion_c.clone(); let cancel_i = conn_cancel_c.clone(); move |ws: axum::extract::ws::WebSocketUpgrade| { + let done_tx = done_tx.lock().unwrap().take().unwrap(); let state_i = Arc::clone(&state_i); let tenant_i = tenant_i.clone(); let assertion_i = assertion_i.clone(); @@ -9772,7 +9782,8 @@ mod tests { conn_time, None, ) - .await + .await; + let _ = done_tx.send(()); }) } } @@ -9866,8 +9877,25 @@ mod tests { // Handler returns after teardown. Wait for the WS connection to close. let _ = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()).await; - // Wait a moment for the handler to finish emitting 48102. - tokio::time::sleep(std::time::Duration::from_millis(200)).await; + tokio::time::timeout(std::time::Duration::from_secs(5), done_rx) + .await + .expect("audio teardown completes") + .unwrap(); + let mut metadata = state + .db + .begin_channel_metadata_write( + community, + channel_id, + state.relay_keypair.public_key(), + ) + .await + .unwrap(); + assert!(metadata.channel().unwrap().archived_at.is_some()); + assert!( + !metadata.needs_snapshot().await.unwrap(), + "auto-archive must publish current metadata" + ); + metadata.rollback().await.unwrap(); // Exactly one 48102 row must exist — the "committed join ⇒ exactly one leave" invariant. let row_48102: i64 = sqlx::query_scalar( diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index a58938d36ef..c4c07b19394 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -13,7 +13,7 @@ use tracing::{error, warn}; /// /// Must comfortably exceed accepted event content sizes after Nostr JSON and /// NIP-44 encryption overhead. -pub const DEFAULT_MAX_FRAME_BYTES: usize = 512 * 1024; +pub use buzz_core::relay::DEFAULT_MAX_FRAME_BYTES; /// Errors that can occur while loading relay configuration. #[derive(Debug, Error)] @@ -745,11 +745,7 @@ impl Config { .and_then(|v| v.parse().ok()) .unwrap_or(1_000); - let max_frame_bytes = std::env::var("BUZZ_MAX_FRAME_BYTES") - .ok() - .and_then(|v| v.parse::().ok()) - .filter(|&v| v > 0) - .unwrap_or(DEFAULT_MAX_FRAME_BYTES); + let max_frame_bytes = buzz_core::relay::max_frame_bytes_from_env(); let slow_client_grace_limit = std::env::var("BUZZ_SLOW_CLIENT_GRACE_LIMIT") .ok() diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 3ab5a8cb907..affb1505092 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -487,7 +487,7 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { let checked = db .verify_channel_metadata_activation(relay_keypair.public_key()) .await?; - info!(checked, "NIP-CL canonical metadata activation audit passed"); + info!(checked, "NIP-CL label-integrity startup audit passed"); } let partition_policy = buzz_db::partition::PartitionMaintenancePolicy { diff --git a/crates/buzz-relay/tests/boot_lifecycle.rs b/crates/buzz-relay/tests/boot_lifecycle.rs index 027fc4d1c26..bf8d1cf3635 100644 --- a/crates/buzz-relay/tests/boot_lifecycle.rs +++ b/crates/buzz-relay/tests/boot_lifecycle.rs @@ -868,4 +868,89 @@ mod postgres_tests { "the sampler must be owned by a relay that finished booting: {logs}" ); } + #[test] + #[ignore = "requires PostgreSQL"] + fn label_integrity_boot_allows_unpublished_topic_and_archive_but_rejects_label_drift() { + let database_url = std::env::var("DATABASE_URL").unwrap(); + let redis_url = std::env::var("REDIS_URL").unwrap(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let (db, community, channel) = runtime.block_on(async { + use buzz_core::channel::{ChannelType, ChannelVisibility}; + let db = buzz_db::Db::new(&buzz_db::DbConfig { + database_url: database_url.clone(), ..Default::default() + }).await.unwrap(); + let community = db.ensure_configured_community("label-restart.example").await.unwrap().id; + let channel = uuid::Uuid::new_v4(); + let keys = nostr::Keys::parse(VALID_RELAY_PRIVATE_KEY).unwrap(); + db.create_channel_with_id(community, channel, "restart", ChannelType::Stream, + ChannelVisibility::Open, None, keys.public_key().as_bytes(), None).await.unwrap(); + sqlx::query("UPDATE channels SET labels=ARRAY['retained']::text[] WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()).bind(channel).execute(db.pool()).await.unwrap(); + let mut write = db.begin_channel_metadata_write(community, channel, keys.public_key()).await.unwrap(); + let snapshot = nostr::EventBuilder::new(nostr::Kind::Custom(39000), "") + .tags(write.snapshot_tags().await.unwrap()).custom_created_at(write.snapshot_timestamp().unwrap()) + .sign_with_keys(&keys).unwrap(); + write.store_snapshot(&snapshot, 512 * 1024).await.unwrap(); + write.commit().await.unwrap(); + // Ordinary writers commit first. Simulate interruption before publication. + db.set_topic(community, channel, "unpublished", keys.public_key().as_bytes()).await.unwrap(); + db.archive_channel(community, channel).await.unwrap(); + (db, community, channel) + }); + let metrics_port = reserve_closed_port(); + let metrics_port_value = metrics_port.to_string(); + let health_port_value = reserve_closed_port().to_string(); + let bind_addr = format!("127.0.0.1:{}", reserve_closed_port()); + let environment = [ + ("BUZZ_RELAY_PRIVATE_KEY", VALID_RELAY_PRIVATE_KEY), + ("BUZZ_NIP_CL_ENABLED", "true"), + ("BUZZ_NIP_CL_WRITER_CUTOVER", "offline-v1"), + ("RELAY_URL", "wss://label-restart.example"), + ("BUZZ_METRICS_PORT", &metrics_port_value), + ("BUZZ_HEALTH_PORT", &health_port_value), + ("BUZZ_BIND_ADDR", &bind_addr), + ("DATABASE_URL", &database_url), + ("REDIS_URL", &redis_url), + ("BUZZ_GIT_CONFORMANCE_PROBE", "false"), + ]; + let mut process = RelayProcess::spawn(&environment); + wait_for_scraped_metric( + &mut process, + metrics_port, + "buzz_readiness_dependency_sample_completed_timestamp_seconds", + ); + let output = process.terminate(); + let logs = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(logs.contains("Health probe listener started"), "{logs}"); + assert!( + logs.contains("NIP-CL label-integrity startup audit passed"), + "{logs}" + ); + runtime.block_on(async { + sqlx::query( + "UPDATE channels SET labels=ARRAY['drift']::text[] WHERE community_id=$1 AND id=$2", + ) + .bind(community.as_uuid()) + .bind(channel) + .execute(db.pool()) + .await + .unwrap(); + }); + let output = run_relay(&environment); + assert!(!output.status.success()); + let logs = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + logs.contains("NIP-CL activation requires metadata repair"), + "{logs}" + ); + assert!(!logs.contains("Health probe listener started"), "{logs}"); + } } diff --git a/docs/channel-labels-rollout.md b/docs/channel-labels-rollout.md index 75fbd89f0d1..6c5e237af30 100644 --- a/docs/channel-labels-rollout.md +++ b/docs/channel-labels-rollout.md @@ -10,11 +10,15 @@ unless the procedure below is enforced by the deployment/database operator. - `BUZZ_NIP_CL_ENABLED=true` enables labeled creation and label mutation and advertises `nip-cl` on mapped community hosts. A configured `BUZZ_RELAY_PRIVATE_KEY` and the exact cutover declaration are required. -- Startup checks every undeleted channel, in bounded pages across communities, - against the configured signer's complete canonical metadata projection. It - fails before listeners/background publishers if repair is needed. It neither - repairs nor establishes fleet exclusion. A community undergoing deletion can - fail admission during this audit; finish its lifecycle before activation. +- Startup checks label integrity in bounded pages across all communities before + listeners/background publishers. Existing heads must be trusted, canonical + and match stored labels; missing heads fail only for labeled channels. + Unpublished ordinary topic/archive updates and missing unlabeled heads do not + block routine restarts: those legacy writes commit before publication. + This audit neither repairs ordinary metadata nor establishes fleet exclusion. + A community undergoing deletion can fail admission during this audit; finish + its lifecycle before activation. Full operator repair below still compares + every canonical metadata field (except the independently refreshed TTL deadline). - Ordinary publishers and full operator reconciliation preserve labels even with the feature disabled. Unlabeled creation retains its legacy admission when disabled; clients must not assume NIP-CL receipts without capability. @@ -38,7 +42,10 @@ unless the procedure below is enforced by the deployment/database operator. fence the entire shared population. Record the observed exclusion, not just a configuration change. Never resume an old binary with the new credential. 4. Apply migrations with the new operator binary (`buzz-admin migrate`) while - routing remains closed. Inventory community hosts from the operator control + routing remains closed. Migration 0061 adds a CHECK to the partitioned event + table and may scan existing partitions while holding access-blocking locks; + budget and measure this step on a representative restored database, not while + serving traffic. Inventory community hosts from the operator control plane. For **each** host run the new binary with the existing stable key: ```bash @@ -49,7 +56,10 @@ unless the procedure below is enforced by the deployment/database operator. Full repair pages through all undeleted channels, including archived ones. `--channel` is intentionally roster-only and does **not** repair metadata. A successful run for one host is not evidence for another host. Resolve any - invalid stored label or oversized snapshot instead of clearing labels. + invalid stored label or oversized snapshot instead of clearing labels. Full + repair checks missing 39001/39002 independently, continues after individual + channel failures, and exits unsuccessfully if any failed. Rerun after fixing + those failures; a partial run is not activation evidence. 5. Start only compatible replicas, still behind closed routing, with: ```text @@ -58,7 +68,7 @@ unless the procedure below is enforced by the deployment/database operator. BUZZ_RELAY_PRIVATE_KEY= ``` - Require the canonical metadata activation audit and readiness to pass on + Require the label-integrity startup audit and readiness to pass on each replica. A config parse pass is not readiness. Verify mapped-host NIP-11 `self` equals the intended relay identity and `supported_extensions` includes `nip-cl`; an unknown host must not advertise it. From 8a313a2d4b364ac28260d64a91ae291aa076e1a0 Mon Sep 17 00:00:00 2001 From: cyberpunk Date: Fri, 9 Oct 2026 20:43:58 -0700 Subject: [PATCH 5/8] test: bind metadata paging check to activation function Signed-off-by: cyberpunk --- crates/buzz-db/tests/observability_source.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buzz-db/tests/observability_source.rs b/crates/buzz-db/tests/observability_source.rs index 9b83f152039..65562b3030f 100644 --- a/crates/buzz-db/tests/observability_source.rs +++ b/crates/buzz-db/tests/observability_source.rs @@ -185,7 +185,7 @@ fn p0_pool_acquisitions_use_typed_operation_pairs_without_other() { .split_once("pub async fn channel_metadata_repair_page(") .expect("metadata repair must expose a paged bootstrap read") .1 - .split_once("/// Verify that every live channel") + .split_once("pub async fn verify_channel_metadata_activation(") .expect("repair paging must precede activation verification") .0; assert!(repair_page.contains("WriterOperation::Bootstrap")); From 52661d57b93e3eba816560a991c74ae01d129704 Mon Sep 17 00:00:00 2001 From: cyberpunk Date: Sat, 10 Oct 2026 14:48:57 -0700 Subject: [PATCH 6/8] fix: tolerate retiring channels and serialize discovery repair Signed-off-by: cyberpunk --- crates/buzz-admin/src/channel_metadata.rs | 40 +-- .../tests/postgres_channel_repair.rs | 81 +++++ crates/buzz-db/src/store/channel_members.rs | 189 +--------- .../src/store/channel_members/snapshot.rs | 329 ++++++++++++++++++ .../src/store/channel_metadata/activation.rs | 34 +- .../activation_postgres_tests.rs | 196 +++++++++++ .../lifecycle_postgres_tests.rs | 2 +- .../buzz-relay/src/handlers/side_effects.rs | 160 ++------- crates/buzz-relay/tests/boot_lifecycle.rs | 15 +- docs/channel-labels-rollout.md | 12 +- 10 files changed, 697 insertions(+), 361 deletions(-) create mode 100644 crates/buzz-db/src/store/channel_members/snapshot.rs diff --git a/crates/buzz-admin/src/channel_metadata.rs b/crates/buzz-admin/src/channel_metadata.rs index cc8d1699e50..6f027508f28 100644 --- a/crates/buzz-admin/src/channel_metadata.rs +++ b/crates/buzz-admin/src/channel_metadata.rs @@ -6,7 +6,7 @@ use buzz_core::{ TenantContext, }; use buzz_db::Db; -use nostr::{EventBuilder, Keys, Kind, Tag}; +use nostr::{EventBuilder, Keys, Kind}; use uuid::Uuid; pub(crate) async fn repair( @@ -122,35 +122,22 @@ async fn reconcile_one( if !admins && !members { return Ok(repaired); } - let roster = db.get_members(tenant.community(), channel).await?; for kind in [KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS] { if (kind == KIND_NIP29_GROUP_ADMINS && !admins) || (kind == KIND_NIP29_GROUP_MEMBERS && !members) { continue; } - let mut tags = vec![Tag::parse(["d", &channel.to_string()])?]; - for member in &roster { - let pk = hex::encode(&member.pubkey); - if kind == KIND_NIP29_GROUP_ADMINS { - if member.role == "owner" || member.role == "admin" { - tags.push(Tag::parse(["p", &pk, &member.role])?); - } - } else { - tags.push(Tag::parse(["p", &pk, "", &member.role])?); - } - } - // Include retired rows: signing the same tags in the same second as a - // deleted head would reproduce its ID and silently lose ON CONFLICT. - let previous: Option = sqlx::query_scalar( - "SELECT EXTRACT(EPOCH FROM max(created_at))::bigint FROM events WHERE community_id=$1 AND channel_id=$2 AND kind=$3 AND pubkey=$4", - ).bind(tenant.community().as_uuid()).bind(channel).bind(kind as i32) - .bind(keys.public_key().as_bytes().as_slice()).fetch_one(db.pool()).await?; - let next = previous - .map(|ts| u64::try_from(ts).ok().and_then(|ts| ts.checked_add(1))) - .unwrap_or(Some(0)) - .ok_or_else(|| anyhow::anyhow!("discovery timestamp overflow"))?; - let timestamp = nostr::Timestamp::from(next.max(nostr::Timestamp::now().as_secs())); + let relay = keys.public_key().to_bytes(); + let mut snapshot = if kind == KIND_NIP29_GROUP_ADMINS { + db.lock_admin_snapshot(tenant.community(), channel, &relay) + .await? + } else { + db.lock_member_snapshot(tenant.community(), channel, &relay) + .await? + }; + let tags = snapshot.snapshot_tags()?; + let timestamp = snapshot.snapshot_timestamp().await?; let keys = keys.clone(); let event = tokio::task::spawn_blocking(move || { EventBuilder::new(Kind::Custom(kind as u16), "") @@ -160,13 +147,12 @@ async fn reconcile_one( .sign_with_keys(&keys) }) .await??; - let (_, inserted) = db - .replace_addressable_event(tenant.community(), &event, Some(channel)) - .await?; + let (_, inserted) = snapshot.replace_discovery_event(&event).await?; anyhow::ensure!( inserted, "discovery publication superseded; rerun reconciliation" ); + snapshot.release().await?; } Ok(true) } diff --git a/crates/buzz-admin/tests/postgres_channel_repair.rs b/crates/buzz-admin/tests/postgres_channel_repair.rs index 35224ec6c89..5fec6619fdb 100644 --- a/crates/buzz-admin/tests/postgres_channel_repair.rs +++ b/crates/buzz-admin/tests/postgres_channel_repair.rs @@ -143,3 +143,84 @@ async fn repair_finishes_scan_but_exits_unsuccessfully_after_a_channel_error() { ); assert!(discovery(&db, &tenant, broken).await.is_empty()); } + +// Same public-coordinate encoding as the production replacement protocol. The +// test takes its lock externally to stop the real binary before roster capture. +fn replacement_key(tenant: &TenantContext, channel: Uuid, keys: &Keys, kind: i32) -> i64 { + let mut hash: u64 = 0xcbf29ce484222325; + for byte in tenant + .community() + .as_uuid() + .as_bytes() + .iter() + .chain(kind.to_le_bytes().iter()) + .chain(keys.public_key().as_bytes().iter()) + .chain(channel.as_bytes().iter()) + { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash as i64 +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn repair_captures_rosters_after_waiting_for_the_replacement_lock() { + let (db, tenant, keys) = fixture().await; + for kind in [39001, 39002] { + let channel = Uuid::new_v4(); + create(&db, &tenant, channel, &keys).await; + assert!(run(&tenant, &keys, None).await.status.success()); + // Full repair must refill 39001; targeted repair always replaces 39002. + if kind == 39001 { + sqlx::query("UPDATE events SET deleted_at=now() WHERE community_id=$1 AND channel_id=$2 AND kind=$3") + .bind(tenant.community().as_uuid()).bind(channel).bind(kind) + .execute(db.pool()).await.unwrap(); + } + let mut holder = db.pool().begin().await.unwrap(); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(replacement_key(&tenant, channel, &keys, kind)) + .execute(&mut *holder) + .await + .unwrap(); + let pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *holder) + .await + .unwrap(); + let newcomer = Keys::generate(); + let change = async { + tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + let blocked: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND $1=ANY(pg_blocking_pids(pid)))") + .bind(pid).fetch_one(db.pool()).await.unwrap(); + if blocked { break; } + tokio::task::yield_now().await; + } + }).await.expect("actual operator must wait behind replacement lock"); + db.add_member( + tenant.community(), + channel, + newcomer.public_key().as_bytes(), + buzz_core::channel::MemberRole::Admin, + Some(keys.public_key().as_bytes()), + ) + .await + .unwrap(); + holder.rollback().await.unwrap(); + }; + let target = (kind == 39002).then_some(channel); + let (output, ()) = tokio::join!(run(&tenant, &keys, target), change); + assert!(output.status.success(), "{output:?}"); + let tags: serde_json::Value = sqlx::query_scalar("SELECT tags FROM events WHERE community_id=$1 AND channel_id=$2 AND kind=$3 AND deleted_at IS NULL") + .bind(tenant.community().as_uuid()).bind(channel).bind(kind).fetch_one(db.pool()).await.unwrap(); + for member in [keys.public_key(), newcomer.public_key()] { + assert!( + tags.as_array() + .unwrap() + .iter() + .any(|tag| tag[0] == "p" && tag[1] == member.to_hex()), + "kind {kind} must include fresh roster and signer self-membership: {tags}" + ); + } + } +} diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 5cf3ed40dcf..c30f0b1d4ac 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -301,189 +301,8 @@ pub async fn insert_auto_membership_in_transaction( // ── End transaction-level helpers ───────────────────────────────────────────── -/// An active member roster captured while holding the channel's membership -/// serialization lock on one writer connection. -pub struct LockedMemberSnapshot { - /// Canonical active members captured behind the lock. - pub members: Vec, - channel_id: Uuid, - relay_pubkey: Vec, - tx: AdmittedTx, -} - -impl LockedMemberSnapshot { - /// The channel whose membership lock this guard holds. - pub fn channel_id(&self) -> Uuid { - self.channel_id - } - - /// Return the newest relay-authored member snapshot timestamp for the - /// locked coordinate, using this guard's existing connection. - pub async fn latest_member_event_timestamp(&mut self) -> Result> { - let value: Option> = sqlx::query_scalar( - "SELECT created_at FROM events WHERE community_id = $1 AND kind = 39002 AND pubkey = $2 AND channel_id = $3 AND deleted_at IS NULL ORDER BY created_at DESC, id ASC LIMIT 1", - ) - .bind(self.tx.community().as_uuid()) - .bind(self.relay_pubkey.as_slice()) - .bind(self.channel_id) - .fetch_optional(self.tx.conn()) - .await?; - Ok(value.map(|timestamp| timestamp.timestamp() as u64)) - } - - /// Replace the relay-authored member snapshot on this guard's existing - /// connection. The membership lock therefore spans capture and replacement - /// without a nested pool checkout. - pub async fn replace_member_event( - &mut self, - event: &nostr::Event, - ) -> Result<(buzz_core::StoredEvent, bool)> { - let community_id = self.tx.community(); - let channel_id = self.channel_id; - if event.pubkey.to_bytes().as_slice() != self.relay_pubkey.as_slice() - || crate::event::extract_d_tag(event) != Some(channel_id.to_string()) - { - return Err(DbError::InvalidData( - "member snapshot replacement does not match its locked coordinate".into(), - )); - } - let kind = buzz_core::kind::event_kind_i32(event); - if kind != 39002 { - return Err(DbError::InvalidData( - "member snapshot replacement requires kind 39002".into(), - )); - } - let pubkey = event.pubkey.to_bytes(); - let created_at_secs = event.created_at.as_secs() as i64; - let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0) - .ok_or(DbError::InvalidTimestamp(created_at_secs))?; - let existing: Option<(chrono::DateTime, Vec)> = sqlx::query_as( - "SELECT created_at, id FROM events WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND channel_id = $4 AND deleted_at IS NULL ORDER BY created_at DESC, id ASC LIMIT 1", - ) - .bind(community_id.as_uuid()) - .bind(kind) - .bind(pubkey.as_slice()) - .bind(channel_id) - .fetch_optional(self.tx.conn()) - .await?; - let incoming_id = event.id.as_bytes().as_slice(); - if let Some((existing_ts, existing_id)) = existing { - if created_at < existing_ts - || (created_at == existing_ts && incoming_id >= existing_id.as_slice()) - { - return Ok(( - buzz_core::StoredEvent::with_received_at( - event.clone(), - Utc::now(), - Some(channel_id), - false, - ), - false, - )); - } - } - sqlx::query("UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND channel_id = $4 AND deleted_at IS NULL") - .bind(community_id.as_uuid()).bind(kind).bind(pubkey.as_slice()).bind(channel_id) - .execute(self.tx.conn()).await?; - let received_at = Utc::now(); - let tags = serde_json::to_value(&event.tags)?; - let sig = event.sig.serialize(); - let inserted = sqlx::query("INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT DO NOTHING") - .bind(community_id.as_uuid()).bind(event.id.as_bytes().as_slice()) - .bind(pubkey.as_slice()).bind(created_at).bind(kind).bind(tags) - .bind(&event.content).bind(sig.as_slice()).bind(received_at).bind(channel_id) - .bind(crate::event::extract_d_tag(event)).execute(self.tx.conn()).await?; - if inserted.rows_affected() == 0 { - return Err(DbError::InvalidData( - "member snapshot event id already exists".into(), - )); - } - crate::store::event_follow_up::after_admitted_insert( - &mut self.tx, - event.id.as_bytes().as_slice(), - kind, - Some(channel_id), - ) - .await?; - crate::insert_mentions_in_transaction(&mut self.tx, event, Some(channel_id)).await?; - Ok(( - buzz_core::StoredEvent::with_received_at( - event.clone(), - received_at, - Some(channel_id), - true, - ), - true, - )) - } - - /// Commit the replacement and release the membership lock. - pub async fn release(self) -> Result<()> { - self.tx.commit().await?; - Ok(()) - } -} - -/// Capture all active members while holding the same per-channel lock used by -/// membership writers. -/// -/// The returned guard must remain alive through publication. This prevents a -/// rolling relay from publishing an older roster after a concurrent add or -/// remove has committed and published newer membership state. -pub async fn lock_member_snapshot( - pool: &PgPool, - community_id: CommunityId, - channel_id: Uuid, - relay_pubkey: &[u8], -) -> Result { - let mut tx = crate::begin_community_event_write_transaction( - pool, - community_id, - crate::observability::WriterOperation::EventWrite, - ) - .await?; - // Match the canonical replacement writer's lock order. Old binaries take - // this key before INSERT; migration 0032 then takes the membership key in - // the INSERT trigger. Taking both in that order avoids mixed-version - // duplicate heads without introducing a lock-order inversion. - let replacement_lock = crate::replaceable::event_replacement_lock_key( - community_id, - 39002, - relay_pubkey, - Some(channel_id.as_bytes()), - ); - crate::observability::observe_advisory_lock( - crate::observability::LockType::Replacement, - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(replacement_lock) - .execute(tx.conn()), - ) - .await?; - acquire_channel_membership_lock(tx.conn(), community_id, channel_id).await?; - let rows = sqlx::query( - r#" - SELECT cm.channel_id, cm.pubkey, cm.role::text AS role, cm.joined_at, cm.invited_by, cm.removed_at - FROM channel_members cm - JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL - WHERE cm.community_id = $1 AND cm.channel_id = $2 AND cm.removed_at IS NULL - ORDER BY cm.joined_at ASC - "#, - ) - .bind(community_id.as_uuid()) - .bind(channel_id) - .fetch_all(tx.conn()) - .await?; - let members = rows - .into_iter() - .map(row_to_member_record) - .collect::>>()?; - Ok(LockedMemberSnapshot { - members, - channel_id, - relay_pubkey: relay_pubkey.to_vec(), - tx, - }) -} +mod snapshot; +pub use snapshot::{lock_member_snapshot, LockedMemberSnapshot}; /// Add a member to a channel. /// @@ -3005,13 +2824,13 @@ mod postgres_tests { .expect("sign roster for another channel"); assert!( matches!( - snapshot.replace_member_event(&other_channel).await, + snapshot.replace_discovery_event(&other_channel).await, Err(DbError::InvalidData(_)) ), "a roster whose d tag names another channel must not replace the locked one" ); let (_, inserted) = snapshot - .replace_member_event(&event) + .replace_discovery_event(&event) .await .expect("replace roster on held connection"); assert!(inserted); diff --git a/crates/buzz-db/src/store/channel_members/snapshot.rs b/crates/buzz-db/src/store/channel_members/snapshot.rs new file mode 100644 index 00000000000..96939f2d01e --- /dev/null +++ b/crates/buzz-db/src/store/channel_members/snapshot.rs @@ -0,0 +1,329 @@ +//! Locked admin/member discovery projections shared by relay and operator repair. + +use buzz_core::{ + kind::{KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS}, + CommunityId, +}; +use chrono::Utc; +use sqlx::PgPool; +use uuid::Uuid; + +use super::{acquire_channel_membership_lock, row_to_member_record, MemberRecord}; +use crate::{AdmittedTx, Db, DbError, Result}; + +/// An active member roster captured while holding the channel's membership +/// serialization lock on one writer connection. +pub struct LockedMemberSnapshot { + /// Canonical active members captured behind the lock. + pub members: Vec, + channel_id: Uuid, + relay_pubkey: Vec, + kind: u32, + tx: AdmittedTx, +} + +impl LockedMemberSnapshot { + /// The channel whose membership lock this guard holds. + pub fn channel_id(&self) -> Uuid { + self.channel_id + } + + /// Build the canonical admin/member tags from the roster held by this guard. + pub fn snapshot_tags(&self) -> Result> { + discovery_tags(self.channel_id, self.kind, &self.members) + } + + /// Advance beyond every prior head, including retired rows, so a repair + /// cannot reproduce a deleted event ID within the same second. + pub async fn snapshot_timestamp(&mut self) -> Result { + let value: Option> = sqlx::query_scalar( + "SELECT created_at FROM events WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND channel_id = $4 ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(self.tx.community().as_uuid()) + .bind(self.kind as i32) + .bind(self.relay_pubkey.as_slice()) + .bind(self.channel_id) + .fetch_optional(self.tx.conn()) + .await?; + let next = value + .map(|timestamp| { + u64::try_from(timestamp.timestamp()) + .ok() + .and_then(|ts| ts.checked_add(1)) + }) + .unwrap_or(Some(0)) + .ok_or_else(|| DbError::InvalidData("discovery timestamp overflow".into()))?; + Ok(nostr::Timestamp::from( + next.max(nostr::Timestamp::now().as_secs()), + )) + } + + /// Replace a member snapshot, retaining the original member-only API. + pub async fn replace_member_event( + &mut self, + event: &nostr::Event, + ) -> Result<(buzz_core::StoredEvent, bool)> { + if self.kind != KIND_NIP29_GROUP_MEMBERS { + return Err(DbError::InvalidData( + "member replacement requires a member guard".into(), + )); + } + self.replace_discovery_event(event).await + } + + /// Replace the relay-authored discovery snapshot on this guard's existing + /// connection. The membership lock therefore spans capture and replacement + /// without a nested pool checkout. + pub async fn replace_discovery_event( + &mut self, + event: &nostr::Event, + ) -> Result<(buzz_core::StoredEvent, bool)> { + let community_id = self.tx.community(); + let channel_id = self.channel_id; + if event.pubkey.to_bytes().as_slice() != self.relay_pubkey.as_slice() + || crate::event::extract_d_tag(event) != Some(channel_id.to_string()) + { + return Err(DbError::InvalidData( + "member snapshot replacement does not match its locked coordinate".into(), + )); + } + let kind = buzz_core::kind::event_kind_i32(event); + if kind != self.kind as i32 { + return Err(DbError::InvalidData( + "discovery snapshot kind does not match its locked coordinate".into(), + )); + } + let pubkey = event.pubkey.to_bytes(); + let created_at_secs = event.created_at.as_secs() as i64; + let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0) + .ok_or(DbError::InvalidTimestamp(created_at_secs))?; + let existing: Option<(chrono::DateTime, Vec)> = sqlx::query_as( + "SELECT created_at, id FROM events WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND channel_id = $4 AND deleted_at IS NULL ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(kind) + .bind(pubkey.as_slice()) + .bind(channel_id) + .fetch_optional(self.tx.conn()) + .await?; + let incoming_id = event.id.as_bytes().as_slice(); + if let Some((existing_ts, existing_id)) = existing { + if created_at < existing_ts + || (created_at == existing_ts && incoming_id >= existing_id.as_slice()) + { + return Ok(( + buzz_core::StoredEvent::with_received_at( + event.clone(), + Utc::now(), + Some(channel_id), + false, + ), + false, + )); + } + } + sqlx::query("UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND channel_id = $4 AND deleted_at IS NULL") + .bind(community_id.as_uuid()).bind(kind).bind(pubkey.as_slice()).bind(channel_id) + .execute(self.tx.conn()).await?; + let received_at = Utc::now(); + let tags = serde_json::to_value(&event.tags)?; + let sig = event.sig.serialize(); + let inserted = sqlx::query("INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT DO NOTHING") + .bind(community_id.as_uuid()).bind(event.id.as_bytes().as_slice()) + .bind(pubkey.as_slice()).bind(created_at).bind(kind).bind(tags) + .bind(&event.content).bind(sig.as_slice()).bind(received_at).bind(channel_id) + .bind(crate::event::extract_d_tag(event)).execute(self.tx.conn()).await?; + if inserted.rows_affected() == 0 { + return Err(DbError::InvalidData( + "member snapshot event id already exists".into(), + )); + } + crate::store::event_follow_up::after_admitted_insert( + &mut self.tx, + event.id.as_bytes().as_slice(), + kind, + Some(channel_id), + ) + .await?; + crate::insert_mentions_in_transaction(&mut self.tx, event, Some(channel_id)).await?; + Ok(( + buzz_core::StoredEvent::with_received_at( + event.clone(), + received_at, + Some(channel_id), + true, + ), + true, + )) + } + + /// Commit the replacement and release the membership lock. + pub async fn release(self) -> Result<()> { + self.tx.commit().await?; + Ok(()) + } +} + +/// Capture all active members while holding the same per-channel lock used by +/// membership writers. +/// +/// The returned guard must remain alive through publication. This prevents a +/// rolling relay from publishing an older roster after a concurrent add or +/// remove has committed and published newer membership state. +pub async fn lock_member_snapshot( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + relay_pubkey: &[u8], +) -> Result { + lock_snapshot( + pool, + community_id, + channel_id, + relay_pubkey, + KIND_NIP29_GROUP_MEMBERS, + ) + .await +} + +impl Db { + /// Capture an admin roster behind the same membership lock as role writers. + /// The guard owns its 39001 replacement coordinate through publication. + pub async fn lock_admin_snapshot( + &self, + community_id: CommunityId, + channel_id: Uuid, + relay_pubkey: &[u8], + ) -> Result { + lock_snapshot( + self.pool(), + community_id, + channel_id, + relay_pubkey, + KIND_NIP29_GROUP_ADMINS, + ) + .await + } +} + +async fn lock_snapshot( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + relay_pubkey: &[u8], + kind: u32, +) -> Result { + let mut tx = crate::begin_community_event_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + // Match the canonical replacement writer's lock order. Old binaries take + // this key before INSERT; migration 0032 then takes the membership key in + // the INSERT trigger. Taking both in that order avoids mixed-version + // duplicate heads without introducing a lock-order inversion. + let replacement_lock = crate::replaceable::event_replacement_lock_key( + community_id, + kind as i32, + relay_pubkey, + Some(channel_id.as_bytes()), + ); + crate::observability::observe_advisory_lock( + crate::observability::LockType::Replacement, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(replacement_lock) + .execute(tx.conn()), + ) + .await?; + acquire_channel_membership_lock(tx.conn(), community_id, channel_id).await?; + sqlx::query("SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))") + .bind(crate::channel::channel_ttl_lock_key( + community_id, + channel_id, + )) + .execute(tx.conn()) + .await?; + let live: Option = sqlx::query_scalar( + "SELECT id FROM channels WHERE community_id=$1 AND id=$2 AND deleted_at IS NULL FOR NO KEY UPDATE", + ).bind(community_id.as_uuid()).bind(channel_id).fetch_optional(tx.conn()).await?; + if live.is_none() { + return Err(DbError::ChannelNotFound(channel_id)); + } + let rows = sqlx::query( + r#" + SELECT cm.channel_id, cm.pubkey, cm.role::text AS role, cm.joined_at, cm.invited_by, cm.removed_at + FROM channel_members cm + JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL + WHERE cm.community_id = $1 AND cm.channel_id = $2 AND cm.removed_at IS NULL + ORDER BY cm.joined_at ASC + "#, + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_all(tx.conn()) + .await?; + let members = rows + .into_iter() + .map(row_to_member_record) + .collect::>>()?; + Ok(LockedMemberSnapshot { + members, + channel_id, + relay_pubkey: relay_pubkey.to_vec(), + kind, + tx, + }) +} + +fn discovery_tags(channel: Uuid, kind: u32, members: &[MemberRecord]) -> Result> { + let tag = |fields: Vec| { + nostr::Tag::parse(fields).map_err(|error| DbError::InvalidData(error.to_string())) + }; + let mut tags = vec![tag(vec!["d".into(), channel.to_string()])?]; + for member in members { + let pk = hex::encode(&member.pubkey); + if kind == KIND_NIP29_GROUP_ADMINS { + if member.role == "owner" || member.role == "admin" { + tags.push(tag(vec!["p".into(), pk, member.role.clone()])?); + } + } else { + // NIP-29: ["p", pubkey, relay_url, role]. The signer is the relay. + tags.push(tag(vec!["p".into(), pk, "".into(), member.role.clone()])?); + } + } + Ok(tags) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn group_members_snapshot_keeps_members_past_one_thousand() { + let channel_id = Uuid::new_v4(); + let members: Vec = (0_u16..1_501) + .map(|index| MemberRecord { + channel_id, + pubkey: vec![(index >> 8) as u8, index as u8], + role: if index == 1_500 { "owner" } else { "member" }.to_string(), + joined_at: chrono::Utc::now(), + invited_by: None, + removed_at: None, + }) + .collect(); + + let tags = + discovery_tags(channel_id, KIND_NIP29_GROUP_MEMBERS, &members).expect("build tags"); + assert_eq!(tags.len(), 1_502, "d tag plus every member p tag"); + + let late_pubkey = hex::encode(&members[1_500].pubkey); + assert!(tags.iter().any(|tag| { + let fields = tag.as_slice(); + fields.len() == 4 + && fields[0] == "p" + && fields[1] == late_pubkey + && fields[3] == "owner" + })); + } +} diff --git a/crates/buzz-db/src/store/channel_metadata/activation.rs b/crates/buzz-db/src/store/channel_metadata/activation.rs index 61e2bf34477..417901aaf61 100644 --- a/crates/buzz-db/src/store/channel_metadata/activation.rs +++ b/crates/buzz-db/src/store/channel_metadata/activation.rs @@ -1,4 +1,4 @@ -//! Bounded startup audit for an offline NIP-CL cutover. +//! Bounded label-integrity audit for NIP-CL startup. use nostr::PublicKey; use uuid::Uuid; @@ -44,15 +44,19 @@ impl Db { /// has completed. This is not a fleet fence: an old binary can still corrupt /// projections if its database access has not been revoked. Each comparison /// uses the same scoped admitted transaction as publication; no captured - /// snapshot or read-replica state establishes readiness. + /// snapshot or read-replica state establishes readiness. Retiring communities + /// and channels are outside the serving set, including retirement between + /// page enumeration and lock acquisition; they do not block healthy tenants. pub async fn verify_channel_metadata_activation(&self, relay: PublicKey) -> Result { let mut cursor = (Uuid::nil(), Uuid::nil()); let mut checked = 0; loop { let channels: Vec<(Uuid, Uuid)> = sqlx::query_as( - "SELECT community_id, id FROM channels WHERE deleted_at IS NULL \ - AND (community_id, id) > ($1, $2) \ - ORDER BY community_id, id LIMIT 128", + "SELECT ch.community_id, ch.id FROM channels ch \ + JOIN communities c ON c.id = ch.community_id \ + WHERE ch.deleted_at IS NULL AND c.deleted_at IS NULL \ + AND c.deletion_state = 'active' AND (ch.community_id, ch.id) > ($1, $2) \ + ORDER BY ch.community_id, ch.id LIMIT 128", ) .bind(cursor.0) .bind(cursor.1) @@ -63,12 +67,24 @@ impl Db { } for (community, channel) in channels { cursor = (community, channel); - let write = self + let write = match self .begin_channel_metadata_write(CommunityId::from_uuid(community), channel, relay) - .await?; + .await + { + Ok(write) => write, + // This constructor performs no actor authorization: AccessDenied + // comes only from community lifecycle admission under its lock. + Err(DbError::AccessDenied(_)) => continue, + Err(error) => return Err(error), + }; + if let Err(error) = write.channel() { + write.rollback().await?; + match error { + DbError::ChannelNotFound(_) => continue, + error => return Err(error), + } + } let stale = if write.previous.is_none() && write.labels().values().is_empty() { - // Still reject a channel deleted after enumeration. - write.channel()?; false } else { !write.has_current_label_snapshot().await? diff --git a/crates/buzz-db/src/store/channel_metadata/postgres_tests/activation_postgres_tests.rs b/crates/buzz-db/src/store/channel_metadata/postgres_tests/activation_postgres_tests.rs index 2c24dae85b1..dcad73ec6cd 100644 --- a/crates/buzz-db/src/store/channel_metadata/postgres_tests/activation_postgres_tests.rs +++ b/crates/buzz-db/src/store/channel_metadata/postgres_tests/activation_postgres_tests.rs @@ -162,3 +162,199 @@ async fn repair_pages_cover_the_catalog_without_crossing_tenants_or_tombstones() } assert_eq!(all, (1..=1001).map(Uuid::from_u128).collect::>()); } + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn activation_skips_retiring_communities_but_checks_healthy_tenants() { + let (db, healthy) = database(false).await; + let relay = Keys::generate(); + let owner = Keys::generate(); + let channel = Uuid::new_v4(); + apply( + &db, + healthy, + &relay, + &command(&owner, channel, 9007, &[("label", "retained")]), + ) + .await; + for state in ["quiescing", "fenced", "tombstone"] { + let community = db + .ensure_configured_community(&format!("{state}.example")) + .await + .unwrap() + .id; + db.create_channel_with_id( + community, + channel, + "retiring", + ChannelType::Stream, + ChannelVisibility::Open, + None, + owner.public_key().as_bytes(), + None, + ) + .await + .unwrap(); + // A non-serving tenant is not an activation prerequisite, even if its + // retained rows would fail canonical label validation. + sqlx::query("UPDATE channels SET labels = ARRAY['INVALID']::text[] WHERE community_id=$1") + .bind(community.as_uuid()) + .execute(db.pool()) + .await + .unwrap(); + let mut lifecycle = db.pool().begin().await.unwrap(); + retire_community(&mut lifecycle, community, state).await; + lifecycle.commit().await.unwrap(); + } + assert_eq!( + db.verify_channel_metadata_activation(relay.public_key()) + .await + .unwrap(), + 1 + ); + sqlx::query("UPDATE channels SET labels=ARRAY['drift']::text[] WHERE community_id=$1") + .bind(healthy.as_uuid()) + .execute(db.pool()) + .await + .unwrap(); + assert!(matches!( + db.verify_channel_metadata_activation(relay.public_key()) + .await, + Err(crate::DbError::InvalidData(_)) + )); + sqlx::query("UPDATE channels SET labels=ARRAY['INVALID']::text[] WHERE community_id=$1") + .bind(healthy.as_uuid()) + .execute(db.pool()) + .await + .unwrap(); + assert!(matches!( + db.verify_channel_metadata_activation(relay.public_key()) + .await, + Err(crate::DbError::InvalidData(_)) + )); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn activation_skips_channels_retired_after_page_load_before_lock() { + let (db, community) = database(false).await; + let relay = Keys::generate(); + let owner = Keys::generate(); + let first = Uuid::from_u128(1); + let labeled = Uuid::from_u128(2); + let headless = Uuid::from_u128(3); + let purged = Uuid::from_u128(4); + for channel in [first, labeled] { + apply( + &db, + community, + &relay, + &command(&owner, channel, 9007, &[("label", "retained")]), + ) + .await; + } + for channel in [headless, purged] { + db.create_channel_with_id( + community, + channel, + "headless", + ChannelType::Stream, + ChannelVisibility::Open, + None, + owner.public_key().as_bytes(), + None, + ) + .await + .unwrap(); + } + let mut blocker = db + .begin_channel_metadata_write(community, first, relay.public_key()) + .await + .unwrap(); + let pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(blocker.tx.conn()) + .await + .unwrap(); + let retire = async { + // The audit cannot request the first channel lock until its entire page + // (including the three later channels) has been read. + super::lifecycle_postgres_tests::blocked_pid(&db, pid).await; + for channel in [labeled, headless] { + assert!(db.soft_delete_channel(community, channel).await.unwrap()); + } + sqlx::query("DELETE FROM channels WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(purged) + .execute(db.pool()) + .await + .unwrap(); + blocker.rollback().await.unwrap(); + }; + let (result, ()) = tokio::join!( + db.verify_channel_metadata_activation(relay.public_key()), + retire + ); + assert_eq!( + result.unwrap(), + 1, + "retired channels are skipped, not counted as verified" + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn activation_skips_community_retirement_between_enumeration_and_admission() { + let (db, healthy) = database(false).await; + let relay = Keys::generate(); + let owner = Keys::generate(); + let channel = Uuid::new_v4(); + apply( + &db, + healthy, + &relay, + &command(&owner, channel, 9007, &[("label", "retained")]), + ) + .await; + for state in ["quiescing", "fenced", "tombstone"] { + let community = db + .ensure_configured_community(&format!("race-{state}.example")) + .await + .unwrap() + .id; + apply( + &db, + community, + &relay, + &command(&owner, channel, 9007, &[("label", "retained")]), + ) + .await; + let mut blocker = db.pool().begin().await.unwrap(); + sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))") + .bind(community.as_uuid()) + .execute(&mut *blocker) + .await + .unwrap(); + let pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *blocker) + .await + .unwrap(); + let retire = async { + super::lifecycle_postgres_tests::blocked_pid(&db, pid).await; + // Match lifecycle's exclusive-lock/update/commit boundary. + retire_community(&mut blocker, community, state).await; + blocker.commit().await.unwrap(); + }; + let (result, ()) = tokio::join!( + db.verify_channel_metadata_activation(relay.public_key()), + retire + ); + assert_eq!(result.unwrap(), 1, "healthy tenant survives {state} race"); + } +} + +async fn retire_community(tx: &mut sqlx::PgConnection, community: CommunityId, state: &str) { + sqlx::query("SELECT set_config('buzz.deletion_executor_community', $1, true), set_config('buzz.deletion_fence_generation', '0', true)") + .bind(community.to_string()).execute(&mut *tx).await.unwrap(); + sqlx::query("UPDATE communities SET deletion_state=$2, deleted_at=CASE WHEN $2='tombstone' THEN now() END WHERE id=$1") + .bind(community.as_uuid()).bind(state).execute(tx).await.unwrap(); +} diff --git a/crates/buzz-db/src/store/channel_metadata/postgres_tests/lifecycle_postgres_tests.rs b/crates/buzz-db/src/store/channel_metadata/postgres_tests/lifecycle_postgres_tests.rs index dcd98d9be03..67776011b4f 100644 --- a/crates/buzz-db/src/store/channel_metadata/postgres_tests/lifecycle_postgres_tests.rs +++ b/crates/buzz-db/src/store/channel_metadata/postgres_tests/lifecycle_postgres_tests.rs @@ -58,7 +58,7 @@ async fn admin(db: &Db, community: CommunityId, channel: Uuid, owner: &Keys) -> admin } -async fn blocked_pid(db: &Db, blocker: i32) -> i32 { +pub(super) async fn blocked_pid(db: &Db, blocker: i32) -> i32 { tokio::time::timeout(Duration::from_secs(5), async { loop { let pid: Option = sqlx::query_scalar( diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index dbc1883dcc6..9ae4d475a7e 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1060,87 +1060,13 @@ pub async fn emit_membership_notification( Ok(()) } -/// Sign, store (replacing previous), and fan-out a single addressable discovery event. -async fn emit_addressable_discovery_event( - tenant: &TenantContext, - state: &Arc, - channel_id: Uuid, - kind: u32, - tags: Vec, - relay_pubkey_hex: &str, -) -> anyhow::Result<()> { - // Ensure the new event's created_at is strictly greater than any existing event - // of the same (kind, pubkey, channel_id). Without this, rapid successive updates - // (e.g. set topic then set purpose in the same second) can produce events with - // identical created_at, causing the second to be rejected by stale-write protection - // (NIP-16 tiebreaker: lower event ID wins, which is random). - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let min_ts = { - let existing = state - .db - .query_events_for_event_write(&buzz_db::event::EventQuery { - kinds: Some(vec![kind as i32]), - channel_id: Some(channel_id), - limit: Some(1), - ..buzz_db::event::EventQuery::for_community(tenant.community()) - }) - .await - .unwrap_or_default(); - existing - .first() - .map(|e| e.event.created_at.as_secs() + 1) - .unwrap_or(now) - }; - let ts = now.max(min_ts); - - let event = EventBuilder::new(Kind::Custom(kind as u16), "") - .tags(tags) - .custom_created_at(nostr::Timestamp::from(ts)) - .sign_with_keys(&state.relay_keypair) - .map_err(|e| anyhow::anyhow!("failed to sign kind:{kind}: {e}"))?; - - let (stored, was_inserted) = state - .db - .replace_addressable_event(tenant.community(), &event, Some(channel_id)) - .await?; - if was_inserted { - let kind_u32 = event_kind_u32(&stored.event); - dispatch_persistent_event(tenant, state, &stored, kind_u32, relay_pubkey_hex, None).await; - } - Ok(()) -} - -fn group_members_tags(group_id: &str, members: &[MemberRecord]) -> anyhow::Result> { - let mut tags: Vec = Vec::with_capacity(members.len() + 1); - tags.push(Tag::parse(["d", group_id])?); - for member in members { - let pubkey_hex = hex::encode(&member.pubkey); - // NIP-29 convention: ["p", pubkey, relay_url, role]. Empty relay_url - // because the canonical relay is implicit (this event is signed by it). - tags.push(Tag::parse(["p", &pubkey_hex, "", &member.role])?); - } - Ok(tags) -} - -async fn store_group_members_event( +async fn store_group_discovery_event( state: &Arc, member_snapshot: &mut buzz_db::channel::LockedMemberSnapshot, + kind: u32, ) -> anyhow::Result> { - let group_id = member_snapshot.channel_id().to_string(); - let tags = group_members_tags(&group_id, &member_snapshot.members)?; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let ts = member_snapshot - .latest_member_event_timestamp() - .await? - .map(|timestamp| timestamp + 1) - .unwrap_or(now) - .max(now); + let tags = member_snapshot.snapshot_tags()?; + let timestamp = member_snapshot.snapshot_timestamp().await?; // A relay-signed roster of a channel the relay is itself a member of (the // relay's moderation-DM key participates in the {relay, recipient} DM used // for moderation notices) MUST retain the relay's own `p` tag. nostr's @@ -1148,13 +1074,13 @@ async fn store_group_members_event( // would drop the relay from the snapshot and fail migration 0032's roster // fence against the canonical two-member DM. `allow_self_tagging` keeps the // snapshot faithful to `channel_members`. - let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_MEMBERS as u16), "") + let event = EventBuilder::new(Kind::Custom(kind as u16), "") .tags(tags) .allow_self_tagging() - .custom_created_at(nostr::Timestamp::from(ts)) + .custom_created_at(timestamp) .sign_with_keys(&state.relay_keypair) - .map_err(|error| anyhow::anyhow!("failed to sign member snapshot: {error}"))?; - let (stored, inserted) = member_snapshot.replace_member_event(&event).await?; + .map_err(|error| anyhow::anyhow!("failed to sign discovery snapshot: {error}"))?; + let (stored, inserted) = member_snapshot.replace_discovery_event(&event).await?; Ok(inserted.then_some(stored)) } @@ -1193,42 +1119,33 @@ pub async fn emit_group_discovery_events( // Metadata must be captured only after acquiring the shared application-owned // transaction boundary. Never give captured stale tags a newer timestamp. super::channel_metadata::publish_metadata(tenant, state, channel_id).await?; - let members = state + let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); + let relay_pubkey = state.relay_keypair.public_key().to_bytes(); + let mut admins = state .db - .get_members_for_event_write(tenant.community(), channel_id) + .lock_admin_snapshot(tenant.community(), channel_id, &relay_pubkey) .await?; - let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); - let group_id = channel_id.to_string(); - - { - let mut tags: Vec = vec![Tag::parse(["d", &group_id])?]; - for m in members - .iter() - .filter(|m| m.role == "owner" || m.role == "admin") - { - let pubkey_hex = hex::encode(&m.pubkey); - tags.push(Tag::parse(["p", &pubkey_hex, &m.role])?); - } - emit_addressable_discovery_event( + let stored = store_group_discovery_event(state, &mut admins, KIND_NIP29_GROUP_ADMINS).await?; + admins.release().await?; + if let Some(stored) = stored { + dispatch_persistent_event( tenant, state, - channel_id, + &stored, KIND_NIP29_GROUP_ADMINS, - tags, &relay_pubkey_hex, + None, ) - .await?; + .await; } - // Re-capture membership behind the writer lock immediately before the - // authoritative 39002 replacement. Metadata has its own fresh projection - // boundary above; admin-list publication retains its existing behavior. - let relay_pubkey = state.relay_keypair.public_key().to_bytes(); + // Capture membership only after locking its replacement coordinate. let mut member_snapshot = state .db .lock_member_snapshot(tenant.community(), channel_id, &relay_pubkey) .await?; - let stored_members = store_group_members_event(state, &mut member_snapshot).await?; + let stored_members = + store_group_discovery_event(state, &mut member_snapshot, KIND_NIP29_GROUP_MEMBERS).await?; member_snapshot.release().await?; dispatch_group_members_event(tenant, state, stored_members, &relay_pubkey_hex).await; @@ -3310,7 +3227,9 @@ pub async fn reconcile_large_channel_member_snapshots( .lock_member_snapshot(candidate.community_id, channel_id, &relay_pubkey.to_bytes()) .await?; let tenant = TenantContext::resolved(candidate.community_id, candidate.host.clone()); - let stored_members = store_group_members_event(state, &mut member_snapshot).await?; + let stored_members = + store_group_discovery_event(state, &mut member_snapshot, KIND_NIP29_GROUP_MEMBERS) + .await?; member_snapshot.release().await?; dispatch_group_members_event(&tenant, state, stored_members, &relay_pubkey_hex).await; Ok::(true) @@ -3592,7 +3511,7 @@ pub async fn publish_dm_visibility_snapshot( // Force created_at strictly past any prior snapshot for this viewer: a same-second // replacement whose random event id sorts higher is rejected by stale-write // protection, so a hide→re-open within one second could otherwise strand the stale - // snapshot. Same guard as emit_addressable_discovery_event. + // snapshot. Use the same monotonic timestamp rule as discovery snapshots. let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -3855,33 +3774,6 @@ mod tests { let _ = call; } - #[test] - fn group_members_snapshot_keeps_members_past_one_thousand() { - let channel_id = Uuid::new_v4(); - let members: Vec = (0_u16..1_501) - .map(|index| MemberRecord { - channel_id, - pubkey: vec![(index >> 8) as u8, index as u8], - role: if index == 1_500 { "owner" } else { "member" }.to_string(), - joined_at: chrono::Utc::now(), - invited_by: None, - removed_at: None, - }) - .collect(); - - let tags = group_members_tags(&channel_id.to_string(), &members).expect("build tags"); - assert_eq!(tags.len(), 1_502, "d tag plus every member p tag"); - - let late_pubkey = hex::encode(&members[1_500].pubkey); - assert!(tags.iter().any(|tag| { - let fields = tag.as_slice(); - fields.len() == 4 - && fields[0] == "p" - && fields[1] == late_pubkey - && fields[3] == "owner" - })); - } - #[test] fn delete_tombstone_omits_absent_moderation_metadata() { let content = diff --git a/crates/buzz-relay/tests/boot_lifecycle.rs b/crates/buzz-relay/tests/boot_lifecycle.rs index bf8d1cf3635..879b59649a5 100644 --- a/crates/buzz-relay/tests/boot_lifecycle.rs +++ b/crates/buzz-relay/tests/boot_lifecycle.rs @@ -870,7 +870,7 @@ mod postgres_tests { } #[test] #[ignore = "requires PostgreSQL"] - fn label_integrity_boot_allows_unpublished_topic_and_archive_but_rejects_label_drift() { + fn label_integrity_boot_tolerates_ordinary_updates_and_retiring_tenants_but_rejects_drift() { let database_url = std::env::var("DATABASE_URL").unwrap(); let redis_url = std::env::var("REDIS_URL").unwrap(); let runtime = tokio::runtime::Runtime::new().unwrap(); @@ -895,6 +895,19 @@ mod postgres_tests { // Ordinary writers commit first. Simulate interruption before publication. db.set_topic(community, channel, "unpublished", keys.public_key().as_bytes()).await.unwrap(); db.archive_channel(community, channel).await.unwrap(); + // Routine restarts must not depend on another tenant finishing its + // deletion drain. Retained channel rows are not serving state. + for state in ["quiescing", "fenced", "tombstone"] { + let retiring = db.ensure_configured_community(&format!("{state}.example")).await.unwrap().id; + db.create_channel_with_id(retiring, channel, "retiring", ChannelType::Stream, + ChannelVisibility::Open, None, keys.public_key().as_bytes(), None).await.unwrap(); + let mut lifecycle = db.pool().begin().await.unwrap(); + sqlx::query("SELECT set_config('buzz.deletion_executor_community', $1, true), set_config('buzz.deletion_fence_generation', '0', true)") + .bind(retiring.to_string()).execute(&mut *lifecycle).await.unwrap(); + sqlx::query("UPDATE communities SET deletion_state=$2, deleted_at=CASE WHEN $2='tombstone' THEN now() END WHERE id=$1") + .bind(retiring.as_uuid()).bind(state).execute(&mut *lifecycle).await.unwrap(); + lifecycle.commit().await.unwrap(); + } (db, community, channel) }); let metrics_port = reserve_closed_port(); diff --git a/docs/channel-labels-rollout.md b/docs/channel-labels-rollout.md index 6c5e237af30..ad400fb466c 100644 --- a/docs/channel-labels-rollout.md +++ b/docs/channel-labels-rollout.md @@ -16,9 +16,11 @@ unless the procedure below is enforced by the deployment/database operator. Unpublished ordinary topic/archive updates and missing unlabeled heads do not block routine restarts: those legacy writes commit before publication. This audit neither repairs ordinary metadata nor establishes fleet exclusion. - A community undergoing deletion can fail admission during this audit; finish - its lifecycle before activation. Full operator repair below still compares - every canonical metadata field (except the independently refreshed TTL deadline). + Retiring communities and deleted/purged channels are outside the serving set + and are skipped, including retirement between page loading and admission. + Admission/database errors other than lifecycle denial still fail startup. + Full operator repair below still compares every canonical metadata field + (except the independently refreshed TTL deadline). - Ordinary publishers and full operator reconciliation preserve labels even with the feature disabled. Unlabeled creation retains its legacy admission when disabled; clients must not assume NIP-CL receipts without capability. @@ -59,7 +61,9 @@ unless the procedure below is enforced by the deployment/database operator. invalid stored label or oversized snapshot instead of clearing labels. Full repair checks missing 39001/39002 independently, continues after individual channel failures, and exits unsuccessfully if any failed. Rerun after fixing - those failures; a partial run is not activation evidence. + those failures; a partial run is not activation evidence. Auxiliary repair + captures and publishes each admin/member roster under the same transaction + locks as relay publishers; it cannot give an unlocked old roster a newer head. 5. Start only compatible replicas, still behind closed routing, with: ```text From a55b9757439cd52b70e3e11a96d9ed1e8f68cd6f Mon Sep 17 00:00:00 2001 From: cyberpunk Date: Sat, 10 Oct 2026 15:01:02 -0700 Subject: [PATCH 7/8] test: bind discovery guard checks to shared snapshot constructor Signed-off-by: cyberpunk --- crates/buzz-db/tests/observability_source.rs | 30 +++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/buzz-db/tests/observability_source.rs b/crates/buzz-db/tests/observability_source.rs index 65562b3030f..9fb3421be57 100644 --- a/crates/buzz-db/tests/observability_source.rs +++ b/crates/buzz-db/tests/observability_source.rs @@ -718,14 +718,24 @@ fn event_write_paths_include_tenant_local_chokepoint_calls() { "parameterized replacements must include a tenant-local chokepoint call" ); - let channel_members = include_str!("../src/store/channel_members.rs"); - let snapshot_lock = channel_members - .split_once("pub async fn lock_member_snapshot(\n") - .expect("channel_members must expose lock_member_snapshot") - .1 - .split_once("/// Add a member to a channel.") - .expect("snapshot lock path must precede member add path") - .0; + let snapshots = include_str!("../src/store/channel_members/snapshot.rs"); + for entry in [ + "pub async fn lock_member_snapshot(", + "pub async fn lock_admin_snapshot(", + ] { + let route = function_slices(snapshots) + .into_iter() + .find(|function| function_header(function).starts_with(entry)) + .expect("discovery entry point must exist"); + assert!( + route.contains("lock_snapshot("), + "{entry} must use the admitted constructor" + ); + } + let snapshot_lock = function_slices(snapshots) + .into_iter() + .find(|function| function_header(function).starts_with("async fn lock_snapshot(")) + .expect("shared snapshot constructor must exist"); assert!( has_any_tenant_local_chokepoint(snapshot_lock), "snapshot publication locks must include a tenant-local chokepoint call" @@ -933,8 +943,8 @@ const GUARDED_WRITE_FUNCTION_EXCEPTIONS: [&str; 3] = [ ]; const GUARDED_TX_ADAPTER_METHOD_PINS: [(&str, &str); 1] = [( - "pub async fn replace_member_event(", - "pub async fn lock_member_snapshot(", + "pub async fn replace_discovery_event(", + "async fn lock_snapshot(", )]; fn production_contains_guarded_write(production_source: &str) -> bool { From 4db7dc64d61a81fd085a98a490cad132ef542db0 Mon Sep 17 00:00:00 2001 From: cyberpunk Date: Sat, 10 Oct 2026 15:05:07 -0700 Subject: [PATCH 8/8] test: retain snapshot pool bypass coverage after extraction Signed-off-by: cyberpunk --- crates/buzz-db/tests/observability_source.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/buzz-db/tests/observability_source.rs b/crates/buzz-db/tests/observability_source.rs index 9fb3421be57..9f8df6fd249 100644 --- a/crates/buzz-db/tests/observability_source.rs +++ b/crates/buzz-db/tests/observability_source.rs @@ -613,6 +613,10 @@ fn p0_pool_acquisitions_use_typed_operation_pairs_without_other() { "channel_members", include_str!("../src/store/channel_members.rs"), ), + ( + "channel_member_snapshots", + include_str!("../src/store/channel_members/snapshot.rs"), + ), ("archived_identities", archived_identities), ("event", event), ("git_repo", include_str!("../src/store/git_repo.rs")),