mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
feat(protocol): define compact membership discovery (BUZZ-615) (#9117)
## Summary Cold login currently downloads full channel rosters to learn the viewer's memberships. This defines compact discovery in NIP-CL and reserves relay-only kind 39008 for a signed, request-bound response. Full kind-39002 rosters remain compatible. The contract binds a complete snapshot to the community, viewer, NIP-98 request and requested channel types. It defines a primary database cut, overflow errors, lazy-roster precedence, reconnect repair and safe fallback across mixed relay versions. The next PR implements both query modes. This PR does not enable either capability. A separate `membership_public` capability uses relay-only kind 39009 for lookups of any subject’s listed-open forum, stream and workflow memberships. It preserves roles and archived state while excluding private, unlisted, DM and system channels. Signed answers bind requester, subject and public scope; they cannot grant membership or sending permissions. The self-mode contract is unchanged. ### Related issue [BUZZ-615](https://linear.app/squareup/issue/BUZZ-615). No duplicate compact-membership implementation found. Related work: [#8898](https://github.com/block/buzz/pull/8898) protects full-roster signing; [#9060](https://github.com/block/buzz/pull/9060) caches membership lookups. ### Testing McKeon independently reviewed the contract and the fixes to request binding, per-channel race fencing and mixed-version fallback. Content checks parse every JSON example and resolve relative links. Paul’s review fixes remove implementation details from the normative text and move the existing 39009 registration and regression test into this PR. Protocol behavior is unchanged. Runtime validation belongs to the implementation PR. This description does not attest human testing. ### Implementation and operator notes Both kinds are registered in `crates/buzz-core/src/kind.rs`, included in `ALL_KINDS`, and rejected as client-submitted events by the relay-only guard. The dependent implementation uses one primary SQL statement’s MVCC snapshot over active `channel_members` joined to nondeleted channels, supported by the partial `(community_id, pubkey) WHERE removed_at IS NULL` index. Public requests share the existing community/requester `ApiCalls` quota configured by `human_api_calls_per_min`. `BUZZ_MEMBERSHIP_SNAPSHOT_ENABLED` and `BUZZ_MEMBERSHIP_PUBLIC_ENABLED` independently default to false. Deploy support across the fleet with both flags off, then enable capabilities independently. Disable the affected capability to roll back; client fallback and back-off behavior remain specified in NIP-CL. Client opt-in and production-shaped throttled validation follow separately. Generated with Codex --------- Signed-off-by: Brad Seiler <seiler@squareup.com> Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
This commit is contained in:
@@ -492,6 +492,12 @@ pub const KIND_WINDOW_BOUNDS: u32 = 39006;
|
||||
/// NIP-CW thread-mode query-time bounds, bound to a normalized newest-first thread request.
|
||||
pub const KIND_THREAD_WINDOW_BOUNDS: u32 = 39007;
|
||||
|
||||
/// NIP-CL: request-bound compact viewer membership overlay; query-time only, never stored.
|
||||
pub const KIND_MEMBERSHIP_SNAPSHOT: u32 = 39008;
|
||||
|
||||
/// NIP-CL: request-bound public membership overlay; query-time only, never stored.
|
||||
pub const KIND_MEMBERSHIP_PUBLIC: u32 = 39009;
|
||||
|
||||
/// Workflow definition (parameterized replaceable, d=workflow_uuid).
|
||||
pub const KIND_WORKFLOW_DEF: u32 = 30620;
|
||||
|
||||
@@ -757,6 +763,8 @@ pub const ALL_KINDS: &[u32] = &[
|
||||
KIND_THREAD_SUMMARY,
|
||||
KIND_WINDOW_BOUNDS,
|
||||
KIND_THREAD_WINDOW_BOUNDS,
|
||||
KIND_MEMBERSHIP_SNAPSHOT,
|
||||
KIND_MEMBERSHIP_PUBLIC,
|
||||
KIND_PRESENCE_UPDATE,
|
||||
KIND_TYPING_INDICATOR,
|
||||
KIND_AGENT_PRESENCE,
|
||||
@@ -912,6 +920,8 @@ pub const fn is_relay_only_kind(kind: u32) -> bool {
|
||||
| KIND_THREAD_SUMMARY
|
||||
| KIND_WINDOW_BOUNDS
|
||||
| KIND_THREAD_WINDOW_BOUNDS
|
||||
| KIND_MEMBERSHIP_SNAPSHOT
|
||||
| KIND_MEMBERSHIP_PUBLIC
|
||||
)
|
||||
}
|
||||
|
||||
@@ -996,6 +1006,15 @@ mod tests {
|
||||
assert!(is_relay_only_kind(KIND_THREAD_WINDOW_BOUNDS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_membership_snapshot_is_relay_only() {
|
||||
assert_eq!(KIND_MEMBERSHIP_SNAPSHOT, 39008);
|
||||
assert!(is_relay_only_kind(KIND_MEMBERSHIP_SNAPSHOT));
|
||||
assert_eq!(KIND_MEMBERSHIP_PUBLIC, 39009);
|
||||
assert!(is_relay_only_kind(KIND_MEMBERSHIP_PUBLIC));
|
||||
assert!(ALL_KINDS.contains(&KIND_MEMBERSHIP_PUBLIC));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameterized_replaceable_range() {
|
||||
assert!(!is_parameterized_replaceable(29999));
|
||||
|
||||
@@ -8,7 +8,8 @@ Channel Identity and Discovery
|
||||
|
||||
This NIP adds identity facts to the relay-signed NIP-29 channel-state events
|
||||
(kinds 39000–39002). With these facts, a client can find channels with
|
||||
ordinary NIP-01 filters. It needs no new filter key and no relay-specific API.
|
||||
ordinary NIP-01 filters. The optional compact membership modes below use
|
||||
explicit filter extensions on the existing `POST /query` bridge.
|
||||
|
||||
## Motivation
|
||||
|
||||
@@ -291,6 +292,469 @@ A relay without this NIP drops label tags on create and refuses
|
||||
a system channel on a relay whose NIP-11 document does not contain `"nip-cl"`.
|
||||
Lookups by label on such a relay find nothing, which is safe.
|
||||
|
||||
## Compact membership modes
|
||||
|
||||
These independent, optional query modes reserve distinct response kinds:
|
||||
|
||||
| Mode / NIP-11 capability | Kind | Subject | Scope |
|
||||
| --- | --- | --- | --- |
|
||||
| `membership_snapshot` v1 | 39008 | NIP-98 signer only | Own active memberships, including private channels and DMs as specified below. |
|
||||
| `membership_public` v1 | 39009 | Any public key | Active memberships in listed, open forum/stream/workflow channels only. |
|
||||
|
||||
Kinds 39008 and 39009 are relay-only, request-bound projections. Support for
|
||||
39008 does not imply support for 39009, or conversely. Each mode key MUST
|
||||
appear only with its own kind; a mismatched mode key and kind returns 400.
|
||||
|
||||
## Compact viewer membership snapshot (version 1)
|
||||
|
||||
This optional capability replaces full-roster discovery for clients that
|
||||
implement the reconciliation rules below. It does not change kinds
|
||||
39000–39003 or their legacy query behavior.
|
||||
|
||||
### Capability and request
|
||||
|
||||
An enabled relay advertises `membership_snapshot` in its host-bound NIP-11
|
||||
information document:
|
||||
|
||||
```json
|
||||
{
|
||||
"membership_snapshot": {
|
||||
"version": 1,
|
||||
"community_id": "<community UUID>",
|
||||
"pubkey": "<relay signing public key>",
|
||||
"kind": 39008,
|
||||
"max_channels": 4096,
|
||||
"max_response_bytes": 2097152
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The operator switch for this capability is off by default.
|
||||
Disabled relays omit the descriptor and return HTTP 404 with
|
||||
`{"error":"membership_snapshot unsupported"}` for this query mode. Capability
|
||||
advertisement alone is not authentication or membership authority. Clients
|
||||
MUST obtain it from the community's TLS origin and pin the relay signing key
|
||||
as they do for group-state events. An unresolved host never advertises it.
|
||||
|
||||
Send one exact filter to the existing `POST /query` bridge:
|
||||
|
||||
```json
|
||||
[{
|
||||
"kinds": [39008],
|
||||
"#p": ["<authenticated viewer public key>"],
|
||||
"#t": ["dm", "forum", "stream", "system", "workflow"],
|
||||
"membership_snapshot": 1
|
||||
}]
|
||||
```
|
||||
|
||||
The nonempty type array MUST be sorted, unique, and contain only known types
|
||||
(`dm`, `forum`, `stream`, `system`, `workflow` in version 1). A client MAY
|
||||
request a subset. Including `system` is an explicit opt-in to system channels,
|
||||
not a change to ordinary NIP-CL reads. Unknown types, versions, extra filter
|
||||
keys, constraints, multiple filters or another viewer return 400. A relay
|
||||
MUST NOT silently narrow the requested set. Future types need a reviewed
|
||||
protocol update; unsupported clients do not discover or revoke them.
|
||||
|
||||
This mode requires a valid NIP-98 event whose `u` and `method` bind the
|
||||
host-derived query URL and POST, and whose `payload` binds the exact body,
|
||||
even when unsigned development bridge reads are enabled. The bridge's normal
|
||||
admission, membership/ban policy, rate limit and replay guard apply. The viewer
|
||||
MUST be the signer proven by NIP-98, never `token_owner` or the NIP-OA
|
||||
owner used for admission. An agent asking for its owner's memberships gets
|
||||
400, even when its auth tag names that owner. Use a
|
||||
fresh NIP-98 event for every attempt (a random auth-event nonce tag avoids
|
||||
same-second duplicates). No second nonce scheme is introduced.
|
||||
|
||||
### Signed response and verification
|
||||
|
||||
Success is an **object**, never an ordinary Nostr event array:
|
||||
|
||||
```json
|
||||
{
|
||||
"membership_snapshot": 1,
|
||||
"event": {
|
||||
"kind": 39008,
|
||||
"pubkey": "<relay signing public key>",
|
||||
"tags": [["d", "<NIP-98 event ID>"], ["p", "<viewer>"]],
|
||||
"content": "<JSON object below>",
|
||||
"id": "<event ID>",
|
||||
"created_at": 1793899000,
|
||||
"sig": "<signature>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The signed content is:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"community_id": "<host-resolved community UUID>",
|
||||
"host": "<canonical resolved request host>",
|
||||
"viewer": "<authenticated viewer public key>",
|
||||
"request_id": "<NIP-98 event ID>",
|
||||
"types": ["dm", "forum", "stream", "system", "workflow"],
|
||||
"complete": true,
|
||||
"snapshot_id": "<SHA-256 content identity>",
|
||||
"channels": [{
|
||||
"id": "<channel UUID>",
|
||||
"role": "guest",
|
||||
"channel_type": "stream",
|
||||
"visibility": "open",
|
||||
"archived": false
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Like the 39005–39007 query overlays, kind 39008 occupies the addressable
|
||||
range but is an on-demand projection: it is never persisted, replaced, broadcast or returned
|
||||
by ordinary REQ, COUNT or `/query`. Clients cannot submit it through EVENT.
|
||||
The `d` tag is a request coordinate, not a durable per-viewer list.
|
||||
|
||||
The client MUST verify the envelope version, full Nostr ID and signature,
|
||||
expected kind and pinned relay key, both tags, and **all** signed binding
|
||||
inputs: schema version, host-resolved community UUID, canonical host (the normalized host of the NIP-98 `u` URL used for this
|
||||
request, not an alias inferred from another community URL), viewer,
|
||||
outstanding NIP-98 event ID and exact requested type array. The request ID
|
||||
MUST belong to the current viewer/community/session generation and be consumed
|
||||
at most once. A canceled, superseded, timed-out or previous-session request
|
||||
cannot establish current membership. `created_at` is informational wall clock;
|
||||
it MUST NOT order responses, prove freshness or override a newer revocation.
|
||||
|
||||
The signature provides verifiable provenance when the result is cached or
|
||||
exported. A saved response is display data, never fresh authorization. Only
|
||||
matching the outstanding request proves an on-demand response; neither its
|
||||
signature nor `snapshot_id` is proof of current membership on its own.
|
||||
|
||||
Arrays, including `[]`, malformed envelopes, missing/false completeness,
|
||||
invalid signatures, binding mismatches, unknown fields needed for authority,
|
||||
duplicate channels or invalid roles/types/visibility MUST fail validation.
|
||||
Never interpret these as an empty complete list. An empty **valid signed**
|
||||
`channels` list is authoritative only for its requested types and snapshot cut.
|
||||
|
||||
### Canonical selection and bounds
|
||||
|
||||
The relay MUST derive the complete response from one consistent read of
|
||||
committed state that reflects every change committed before the relay
|
||||
received the request. Select only the viewer's active memberships in nondeleted
|
||||
channels in the same community, ordered by channel UUID. Channel UUID,
|
||||
role, type, visibility and archive state MUST reflect that same read.
|
||||
The projection MUST NOT include full rosters, other members' keys or profiles.
|
||||
|
||||
There is one entry per active membership in the requested type set:
|
||||
|
||||
- Roles retain `owner`, `admin`, `member`, `bot`, and `guest`. A guest remains
|
||||
read-only under existing send-time policy; membership is not a write grant.
|
||||
- Open, private, unlisted and explicitly requested system memberships are
|
||||
included. Open channels where the viewer is not a member are excluded.
|
||||
- Archived channels are included with `archived:true`; deleted channels and
|
||||
removed memberships are excluded.
|
||||
- Hidden DMs are included, as in kind-39002 discovery. NIP-DV kind 30622 owns
|
||||
the separate per-viewer hide overlay; hiding is not leaving a DM.
|
||||
- Names, avatars, labels, creation identity, join policy and other channel
|
||||
metadata remain in kind 39000. No member list is needed to use this result.
|
||||
|
||||
The snapshot identity is SHA-256 of the UTF-8 compact JSON array
|
||||
`["nip-cl-membership-state-v1", community_id, viewer, types, channels]`, with
|
||||
UUIDs/pubkeys in lowercase and channel object keys in the order shown above.
|
||||
Clients MUST recompute this identity from the verified signed fields; it
|
||||
describes content, not a monotone revision, transaction ID or live cursor.
|
||||
A role change, removal or channel-state transition committed after the cut is
|
||||
visible on a subsequent refresh, even if legacy roster re-signing fails.
|
||||
|
||||
Version 1 permits at most 4096 channels and 2097152 encoded response bytes.
|
||||
Selection probes one extra row. Either overflow returns 413 for the whole
|
||||
request, with no success envelope or partial list. Database/signing failure
|
||||
returns an error, never complete empty state. There are no pages, tokens,
|
||||
server snapshot caches, or resume semantics in v1. Retry the whole projection
|
||||
with fresh auth; a larger-account paging design requires a new version with
|
||||
an immutable cut, authorization, expiry and resource bounds.
|
||||
|
||||
### Authority and snapshot-to-live handoff
|
||||
|
||||
A snapshot describes one committed cut; it does **not** fence live delivery.
|
||||
The supported client path MUST keep viewer membership and role authority
|
||||
separate from full-roster availability:
|
||||
|
||||
1. Establish the authenticated viewer's live 44100/44101 subscription for all
|
||||
requested types before issuing the snapshot. Treat these notices as hints,
|
||||
never an ordered or lossless delta log. Observe connection gaps, explicit
|
||||
denials and changes to projected archive/visibility/type/deletion state as
|
||||
invalidations too. Names or avatars alone do not dirty this projection.
|
||||
2. Fence the request by account, tenant and session generation. Increment an
|
||||
invalidation generation for every relevant hint received after dispatch.
|
||||
Buffer these hints while loading. A changed generation makes the result
|
||||
dirty: for every channel named by a hint after dispatch, ignore that
|
||||
result's entry entirely (presence, absence and role). Keep prior state
|
||||
marked stale, including any denial already applied; suspend actions that
|
||||
depend on an unconfirmed role. For unhinted channels a dirty result may
|
||||
only add memberships, never alter existing roles or remove by omission.
|
||||
A global invalidation discards the whole result. Coalesce a follow-up
|
||||
refresh after processing the hints. A clean valid
|
||||
snapshot may reconcile omissions only within its exact requested type set.
|
||||
3. Serialize refreshes per viewer/community and fence delayed completions.
|
||||
Never order snapshots by timestamps or hashes. Local access denials or
|
||||
removal/deletion hints immediately invalidate subscriptions, dependent UI
|
||||
and roster caches; a delayed response cannot undo them. A later clean
|
||||
request may establish a real re-add. Duplicate/out-of-order hints may cause
|
||||
redundant invalidation, never grant authority.
|
||||
4. Lazy kind-39002 responses serve other-member features. They MUST NOT add,
|
||||
remove or change the viewer's own membership or role on this path, even
|
||||
when their timestamp is newer. Contradictions invalidate and schedule a
|
||||
compact refresh. Preserve existing send-time permission checks; a roster
|
||||
or snapshot cannot override a relay denial.
|
||||
5. Refresh after every reconnect/gap before claiming freshness. Reconnect costs
|
||||
one compact projection per viewer, never one roster read per channel. On
|
||||
resume, reconnect or capability changes, recheck the host-bound descriptor.
|
||||
Coalesce notices, jitter dispatch and exponential failure backoff, and cap
|
||||
immediate repair to three attempts per recovery episode. Continuous churn
|
||||
then leaves explicit stale/incomplete state until the next bounded episode;
|
||||
it MUST NOT start an endless roster reload or certify dirty omissions.
|
||||
6. Because notifications are post-commit and may be lost, refresh on foreground
|
||||
resume and use a jittered 15-minute backstop while active, with no overlapping
|
||||
reads. This bounds **attempts**, not successful convergence during outage or
|
||||
churn. Successful quiescent refresh repairs missed roles, grants, revocations,
|
||||
deletion and metadata transitions. Display stale state when repair fails;
|
||||
the relay still authorizes every operation against current access.
|
||||
|
||||
The relay continues to enforce current read/write authorization independently.
|
||||
This response is discovery authority, never a bearer capability. Relay-wide
|
||||
ban/removal invalidates the session even if channel rows remain active.
|
||||
|
||||
### Compatibility
|
||||
|
||||
A client MUST require the descriptor before selecting this mode. An explicit
|
||||
absent/disabled capability uses legacy full-roster discovery. If a pod returns
|
||||
an ordinary array or the exact disabled 404 error after another pod advertised
|
||||
support, mark compact discovery unsupported for that generation, recheck the
|
||||
descriptor and back off capability probes. Explicitly select a new legacy
|
||||
authority/session generation and run full legacy validation; the array is
|
||||
never a complete compact snapshot. This handles mixed-version fleets without
|
||||
revocation by empty response. An auth error,
|
||||
invalid signature, malformed envelope or transient failure MUST NOT silently
|
||||
switch authority models. Old relays may ignore unknown filter fields and
|
||||
return an ordinary array; this is not a valid compact response. Rollback may
|
||||
explicitly select legacy discovery with a new authority/session generation,
|
||||
without deleting saved display/user data. New relays keep legacy 39002 events,
|
||||
query shape, type opt-ins and signatures intact. Disabling the capability
|
||||
requires clients to recheck support and explicitly select legacy authority
|
||||
as above.
|
||||
|
||||
## Compact public membership lookup (version 1)
|
||||
|
||||
This independent mode answers “which public channels is this person in?”
|
||||
without loading their peers' roster entries. It does not relax the self-mode
|
||||
rule that kind 39008's `#p` MUST equal the NIP-98 signer. In this section,
|
||||
**requester** means the authenticated signer and **subject** means the public
|
||||
key whose memberships are requested. “Public” means listed and open within
|
||||
the host-resolved community, not accessible across community boundaries.
|
||||
|
||||
### Capability and request
|
||||
|
||||
An enabled relay advertises this separate descriptor in its host-bound
|
||||
NIP-11 information document:
|
||||
|
||||
```json
|
||||
{
|
||||
"membership_public": {
|
||||
"version": 1,
|
||||
"community_id": "<community UUID>",
|
||||
"pubkey": "<relay signing public key>",
|
||||
"kind": 39009,
|
||||
"max_channels": 4096,
|
||||
"max_response_bytes": 2097152
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The operator switch for public lookup is off by default and independent of
|
||||
the self-snapshot switch. Disabled relays omit this descriptor and
|
||||
return HTTP 404 with `{"error":"membership_public unsupported"}` for this
|
||||
mode. An unresolved host never advertises it. Clients MUST obtain and pin
|
||||
this descriptor using the TLS-origin and relay-key rules of self mode.
|
||||
|
||||
Send exactly one filter to `POST /query`:
|
||||
|
||||
```json
|
||||
[{
|
||||
"kinds": [39009],
|
||||
"#p": ["<subject public key>"],
|
||||
"#t": ["forum", "stream", "workflow"],
|
||||
"membership_public": 1
|
||||
}]
|
||||
```
|
||||
|
||||
`#p` MUST contain exactly one lowercase 64-character hex public key. The
|
||||
nonempty `#t` array MUST be sorted, unique, and contain only `forum`,
|
||||
`stream`, or `workflow`; a subset is allowed. Unknown types (including `dm`
|
||||
and `system`), versions, extra keys or constraints, multiple filters, mixed
|
||||
kinds, or mixing `membership_public` with `membership_snapshot` return 400.
|
||||
A relay MUST NOT silently narrow the requested set.
|
||||
|
||||
A valid fresh NIP-98 event MUST bind the exact body, canonical host-derived
|
||||
query URL and POST method, even when unsigned development reads are enabled.
|
||||
The normal bridge admission, community membership/ban policy and replay
|
||||
guard apply to the requester. Any requester admitted by those rules MAY
|
||||
query any subject, including themselves or their owner. The requester is
|
||||
always the signer, never `token_owner` or the NIP-OA owner. No relationship
|
||||
between requester and subject expands the public scope or grants private
|
||||
access. A subject need not have a profile or current community membership;
|
||||
selection uses their active channel memberships in this community only.
|
||||
|
||||
Every attempt MUST consume the requester's existing per-community API-call
|
||||
quota before selection. Changing subjects MUST NOT
|
||||
reset that quota. Exhaustion returns 429 using the bridge's existing
|
||||
`rate-limited: quota exceeded; retry in <seconds>s` error convention; shared
|
||||
admission unavailability returns 503 and fails closed. Clients MUST respect
|
||||
that retry delay, back off and jitter further attempts; no new per-subject
|
||||
quota or auth nonce scheme is introduced.
|
||||
|
||||
### Signed response and verification
|
||||
|
||||
Success is an object with its own version marker and response kind:
|
||||
|
||||
```json
|
||||
{
|
||||
"membership_public": 1,
|
||||
"event": {
|
||||
"kind": 39009,
|
||||
"pubkey": "<relay signing public key>",
|
||||
"tags": [["d", "<NIP-98 event ID>"], ["p", "<subject>"]],
|
||||
"content": "<JSON object below>",
|
||||
"id": "<event ID>",
|
||||
"created_at": 1793899000,
|
||||
"sig": "<signature>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The signed content is:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"scope": "public",
|
||||
"community_id": "<host-resolved community UUID>",
|
||||
"host": "<canonical resolved request host>",
|
||||
"requester": "<NIP-98 signer public key>",
|
||||
"subject": "<requested public key>",
|
||||
"request_id": "<NIP-98 event ID>",
|
||||
"types": ["forum", "stream", "workflow"],
|
||||
"complete": true,
|
||||
"snapshot_id": "<SHA-256 content identity>",
|
||||
"channels": [{
|
||||
"id": "<channel UUID>",
|
||||
"role": "member",
|
||||
"channel_type": "stream",
|
||||
"visibility": "open",
|
||||
"archived": false
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Kind 39009 is relay-only, query-time data: it MUST NOT be persisted,
|
||||
replaced, broadcast, returned by ordinary REQ, COUNT or `/query`, or accepted
|
||||
through client EVENT. Its `d` tag is a request coordinate, not a durable list.
|
||||
|
||||
The client MUST verify the envelope marker/version, full Nostr ID and
|
||||
signature, kind 39009 and pinned relay key, exactly one `d` and one `p` tag
|
||||
with the values shown, and every signed binding: schema version,
|
||||
`scope:"public"`, community UUID, canonical host (the normalized host of the
|
||||
NIP-98 `u` URL for this request), requester equal to the current signer,
|
||||
subject equal to the requested `#p`, outstanding request ID and exact type
|
||||
array. The request MUST belong to the current requester/community/session
|
||||
and the currently selected subject; consume it at most once. Canceled,
|
||||
superseded, timed-out or previous-session results MUST NOT replace current
|
||||
discovery data. Neither timestamps nor hashes order responses.
|
||||
|
||||
Arrays (including `[]`), malformed envelopes, missing/false completeness,
|
||||
invalid signatures, binding mismatches, unknown fields needed to interpret
|
||||
the result, duplicate channel IDs, invalid roles/types, non-boolean archive
|
||||
state or visibility other than `open` MUST fail validation. Failure is never
|
||||
a complete empty list. A valid signed empty list means only that no matching
|
||||
public memberships existed at that cut. A saved response is display data;
|
||||
its signature and identity do not prove present visibility or authorization.
|
||||
|
||||
### Selection, completeness and bounds
|
||||
|
||||
The relay MUST derive the complete response from one consistent read of
|
||||
committed state that reflects every change committed before the relay
|
||||
received the request. Select the subject's active memberships in nondeleted
|
||||
channels in the same community, restricted to requested types, open and not
|
||||
unlisted, ordered by channel UUID. This is the listed-open discovery
|
||||
predicate, with no shared-private-channel exception. All entry fields MUST
|
||||
reflect that same read. The projection MUST NOT include full rosters,
|
||||
peers' public keys or profiles.
|
||||
|
||||
There is one entry per matching membership. Preserve the subject's role
|
||||
(`owner`, `admin`, `member`, `bot` or `guest`); include archived channels with
|
||||
`archived:true`. Exclude removed memberships, deleted channels, private or
|
||||
unlisted channels, DMs and system channels, even when the requester is a
|
||||
member too. Channel names and other metadata remain in kind 39000 under
|
||||
existing read rules.
|
||||
|
||||
`complete:true` means all of the subject's memberships satisfying this
|
||||
public predicate and requested type set at that instant. It never means
|
||||
the subject's full membership set. A channel absent on refresh may have
|
||||
become private, unlisted or deleted, or its membership may have ended;
|
||||
absence MUST NOT establish loss of membership.
|
||||
|
||||
The snapshot identity is SHA-256 of the UTF-8 compact JSON array
|
||||
`["nip-cl-public-membership-v1", community_id, subject, types, channels]`,
|
||||
with UUIDs/pubkeys in lowercase and channel object keys in the order shown.
|
||||
Clients MUST recompute it from the verified signed fields. Requester and
|
||||
request ID bind the signed response but are excluded from content identity;
|
||||
identical public sets may have identical identities across requesters.
|
||||
The identity is neither a revision nor a live cursor.
|
||||
|
||||
The same bounds as self mode apply: at most 4096 matching channels and
|
||||
2097152 encoded response bytes, probing one extra matching row. Either
|
||||
overflow returns 413 for the whole request, with no success envelope or
|
||||
partial list. Database/signing failure returns an error, never complete
|
||||
empty state. There are no pages, tokens, server snapshot caches or resume
|
||||
semantics in v1. A retry requires fresh NIP-98 auth.
|
||||
|
||||
### Discovery authority and compatibility
|
||||
|
||||
A validated result MAY replace a cached public discovery list keyed by
|
||||
community, subject and exact requested types. It MUST NOT populate or alter
|
||||
the requester's own membership/role authority, canonical kind-39002 roster
|
||||
caches, send permissions or the self-mode 44100/44101 snapshot-to-live
|
||||
handoff, even when requester equals subject. Roles describe the subject;
|
||||
they are not grants to the requester. The self-mode handoff does not apply.
|
||||
Ordinary read/write authorization remains independently enforced, and
|
||||
saved public data MUST NOT authorize a read after a visibility change.
|
||||
|
||||
Clients MUST require `membership_public` before selecting this mode. Absence
|
||||
or the exact disabled 404 means this lookup is unsupported; an ordinary
|
||||
array from an older relay is never a valid public result. If a pod returns
|
||||
an ordinary array or the exact disabled 404 after another pod advertised
|
||||
support, mark public lookup unsupported for that generation, recheck its
|
||||
host-bound descriptor and back off capability probes. Keep any saved public
|
||||
list explicitly stale; this does not switch the self authority model.
|
||||
Auth failures, invalid signatures, malformed responses and transient errors
|
||||
MUST NOT be accepted as an empty result or silently retried through self mode.
|
||||
Public and self capabilities can be enabled and disabled independently.
|
||||
Turning off public lookup stops its use without changing the
|
||||
self-membership authority/session generation or deleting saved display data.
|
||||
|
||||
### Normative examples and rejection cases
|
||||
|
||||
In these examples A signs fresh body-bound NIP-98 auth and B is the subject:
|
||||
|
||||
| Request / response | Required outcome |
|
||||
| --- | --- |
|
||||
| A requests B's `forum,stream,workflow` memberships; B belongs to listed-open stream X, archived listed-open forum Y, private Z shared with A, an unlisted stream, a DM and a system channel. | Return X and Y only, preserving B's roles and marking Y archived. |
|
||||
| A is B's agent and its admission auth tag names B as owner. | The same public-only X/Y result; no owner's private memberships. |
|
||||
| A requests A with `membership_public:1`. | The same public predicate; never private/DM/system memberships or self authority. |
|
||||
| A requests B with `membership_snapshot:1`, kind 39008. | 400; self mode still requires subject equal to signer. |
|
||||
| Public request contains `#t:["dm","stream"]`, `#t:["system"]`, duplicate/unsorted types, two subjects, both mode keys, or an added `limit`. | 400; never silently drop forbidden constraints or types. |
|
||||
| A receives a validly signed answer for requester C, subject C, another host/community, another type subset or another request ID. | Reject the binding mismatch. A's outstanding request is unchanged. |
|
||||
| A receives kind 39008 or the self envelope for its public request, or kind 39009 for its self request. | Reject the cross-mode response, including when A equals B. |
|
||||
| X becomes private or unlisted before a later lookup's cut. | Omit X from the new public list even if A still shares X; replacing the public cache is allowed, revoking membership from the omission is forbidden. |
|
||||
| B has no matching public memberships, or has never had a community profile. | Return a valid signed complete empty list if selection is empty; do not disclose excluded memberships. |
|
||||
| Selection or encoding overflows, selection/signing fails, or requester quota is exhausted. | Return the specified whole-request error; never a partial or complete-empty success. |
|
||||
|
||||
## Trust
|
||||
|
||||
A client that relies on these tags MUST check that the relay's key signed the
|
||||
|
||||
Reference in New Issue
Block a user