Files
buzz/docs/multi-tenant-conformance.md
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

17 KiB

Multi-tenant Conformance Checklist

This document is the source-vs-model checklist for adding first-class communities without changing the observed behavior of a single-community Buzz deployment.

The compatibility rule is: today's Buzz is one implicit community selected by its relay URL. Multi-tenant Buzz makes that selector explicit at the backend boundary while preserving the Nostr wire format, existing REST paths, channel UUIDs, event shapes, media URLs, git Smart HTTP behavior, workflow behavior, and CLI/Desktop/MCP expectations when N = 1.

Row zero: request community binding

Every external request starts with exactly one community:

req.community = resolve_host(connection.host), bound at connection establishment, before any WebSocket EVENT/REQ, REST handler, media handler, git transport handler, webhook handler, workflow side effect, search query, or pub/sub fan-out path observes tenant data.

Conformance obligations:

  • The URL host is the authoritative community selector. This preserves today's "the relay URL is the thing I connected to" semantic while lifting it one level up from relay process to community.
  • Unknown or unmapped hosts fail closed with a generic rejection; they never fall through to a default tenant.
  • NIP-98/API-token/community stamps may narrow or authenticate authority, but they never override the host-derived community. A token whose community stamp disagrees with req.community is rejected.
  • A client-supplied h tag is adversarial input. If present, it must resolve to a channel inside req.community; if absent, the event is channel-less but still community-scoped as community_id = req.community.
  • The single-community deployment is the degenerate case: one configured host resolves to the one default community, so existing clients observe the same behavior.

Conformance table

Surface Today's observable behavior Tenant source Community-global vs operator-global Required DB/index/RLS scope Auth/fan-out/search effects Single-community compatibility check Open decision/test
Row zero: host binding A user connects to one relay URL and all state they can observe belongs to that relay. resolve_host(connection.host) before handler entry. Community-global selector; operator only manages the host map. communities(host, id, signing_key, …); every scoped table references immutable community_id. All auth, event, REST, media, git, search, pub/sub, and workflow paths consume TenantContext; host/token mismatch rejects generically. One host maps to the default community; no client-visible protocol field changes. Add model/prose gate that ctx.community is derived from host, not supplied by the client.
NIP-11 relay info and relay self GET ///info returns one relay info document; RelayInfo::build advertises static NIPs, a stable relay signing pubkey when configured, and the community's workspace icon (NIP-WP). Host-derived community for community-specific facts; the workspace icon is pre-fetched as a scalar via bind_community (fail-open to absent on unmapped host); no other DB lookup from unauthenticated global state unless explicitly through TenantContext. NIP-11 is community-global. Operator-global software/version may be shared; relay self for group/system/audit signing is per-community; icon is per-community intentionally-public presentation. communities.signing_key or equivalent per-community signing material; no platform-global self for tenant-observable system events. Unauthenticated reads must not become enumeration oracles for other communities: apart from the requesting host's own icon, no field varies by community, and an unmapped host's document carries no icon. NIP-43 advertisement reflects membership enforcement for that community only. One community returns the same JSON except for values already configured today. Signature/static-input lint remains: RelayInfo::build must not grow unscoped DB/search/audit inputs — host-scoped scalars (like icon) are passed in pre-derived.
API tokens and NIP-98 replay API/NIP-98 clients authenticate REST/media/git; API tokens may carry scopes and channel IDs; NIP-98 replay uses an in-process seen-set today. Host-derived community plus token's stamped community; stamps must agree. Community-global token namespace; operator-global only for deployment health/secrets. api_tokens gains community_id; token hash uniqueness and lookup are (community_id, token_hash) or the token cryptographically embeds community and lookup verifies both. Channel claims must reference channels in the same community. Replay seen-set key is (community_id, event_id) in shared HA storage or equivalent sticky routing; NIP-98 u URL host must match req.community. Existing single-community tokens continue to authorize the same scopes/channels after backfill to default community. HA gate: Redis/shared seen-set with atomic insert-if-absent and TTL ≥ replay window, or documented single-replica/sticky alternative.
Relay membership, pubkey allowlist, archived identities relay_members, pubkey_allowlist, and archived_identities are relay-global gates over pubkeys. Host-derived community for tenant access; operator context only for platform administration. Community-global membership/allowlist/archive by default. Operator-global only for explicit platform ops tables that are never tenant-observable. Add community_id to these tables; primary/unique keys become (community_id, pubkey) and indexes include community_id. Membership errors remain generic. NIP-OA owner checks test owner membership in the same community. Identity archive requests cannot hide/archive a key in another community. One default community preserves today's closed/open relay behavior and admin CLI semantics after commands target the default community. Decide any future operator-global super-admin surface separately; do not reuse tenant membership tables for it.
Users, profiles, NIP-05, and user search Kind:0 updates sync a users row; NIP-05 handles are unique; /api/users/search searches display name/NIP-05/pubkey. Channel-less events use req.community; NIP-05 domain is the connected community host. Community-global. Same pubkey can have one profile per community; users repost kind:0 in each community they join. users gains community_id; keys/uniques are (community_id, pubkey), (community_id, lower(nip05_handle)), and (community_id, okta_user_id) where applicable. Profile event replacement is scoped by (community_id, pubkey). Search and batch profile reads include community_id; NIP-05 lookup only resolves handles for the requested host/community. No cross-community profile inheritance. Existing users backfill into the default community; profile APIs and CLI output stay unchanged. Add tests for same pubkey with different profile/NIP-05 in two communities and for NIP-05 same local part on two hosts.
Channel-less global events and DMs Events with channel_id = NULL include profiles, DMs, lists, status, long-form, engrams, membership notifications, workflow commands, and repo announcements; global subscriptions use p/kind gates. req.community when no h tag is present. Community-global. "Global" means visible across channels inside one community, never across communities. DMs are per-community. events, event_mentions, replaceable/NIP-33 indexes, reactions, thread metadata, feed tables, and direct ID lookup helpers include community_id. NIP-33 uniqueness is (community_id, kind, pubkey, d_tag). REQ, /query, /count, feed, direct GET /api/events/{id}, deletes, reactions, and thread lookups filter by community before event id/pubkey/kind matching. Single-community global subscriptions and DMs still behave as today. Regression tests for same event id/d-tag/pubkey in two communities and for DM #p not cross-delivering.
Channels and channel membership channel_id (h tag) is the only locality boundary; channels, membership, canvas, topic, DMs, NIP-29 discovery are channel-scoped. resolve(h) must equal req.community; channel creation writes community_id = req.community. Community-global channel namespace; channel-local for channel content. channels, channel_members, canvas/topic/DM participant hashes, NIP-29 group ids, and channel indexes include community_id. channels.community_id is immutable. Mixed-community or unknown h tags reject generically. Open-channel discovery lists only channels in the community. Existing channel UUIDs and h tags remain valid after default-community backfill. Migration lint forbids channel re-tenanting except through an explicitly modeled admission path.
Workflows, runs, approvals, webhooks, schedules Workflows are channel-scoped or project/channel-global; triggers fire on matching stored events; schedule/webhook/manual triggers create runs; approval tokens are hashed. Workflow definition's community from req.community at create/update; webhook/schedule/manual routes resolve workflow id inside host-derived community. Community-global workflow namespace; runs/approvals inherit workflow community. workflows, workflow_runs, workflow_approvals include community_id; workflow id/token hash lookups are scoped; trigger event ids are scoped. Trigger evaluation only sees events in the same community. Webhook URLs include host-derived community; approval token grants cannot act on another community's same hash/id. Existing workflow APIs and YAML remain unchanged in default community. Add tests for identical workflow UUID/approval token hash in different communities and schedule execution isolation.
Search / FTS Postgres FTS over the events.search_tsv generated tsvector column (GIN-indexed); searchable rows expose id, content, kind, pubkey, optional channel_id, created_at, tag terms; channel-less scope is ChannelScope::ChannelLessOnly; the relay refetches canonical events from Postgres by hit id. Search query carries req.community; searchable rows carry community_id. Community-global search results; operator-global FTS index infrastructure may be shared. Every search query filters by community_id, BitmapAnd-ed with the GIN @@ probe; refetch by (community_id, event_id). Every query carries community_id plus channel scope. ChannelLessOnly means channel-less within the community, not platform global. One community produces the same search results as today. Tests for same event id/content in A and B, deletion in A not deleting B.
Redis pub/sub, presence, typing, and cache invalidation Event fan-out uses buzz:channel:{uuid}; presence uses buzz:presence:{pubkey}; typing uses buzz:typing:{channel_id}; cache invalidation uses buzz:cache-invalidate. Pub/sub calls receive TenantContext and derive keys from community_id plus channel/pubkey. Pub/sub and presence are community-global; Redis deployment is operator-global shared infrastructure. Redis keys include community: buzz:{community}:channel:{uuid}, buzz:{community}:presence:{pubkey}, buzz:{community}:typing:{channel_id}, and community-aware cache invalidation payloads/channels. Cross-node fan-out must not deliver events to subscriptions in another community. Same pubkey can be online/away differently in two communities. Cache drops only affect same-community membership/visibility caches unless explicitly all-community operator maintenance. Single-community can preserve existing key names only if deployment is isolated; shared multi-tenant Redis must use the prefixed form. Add tests for same pubkey presence in two communities and same channel UUID collision in two communities.
Media / Blossom / S3 Authenticated uploads return content-addressed descriptors; GET/HEAD /media/{sha256.ext} requires a Blossom t=get auth event scoped to the serving host or the blob hash and binds the read to the header-resolved tenant, so a bare read is rejected before any storage lookup; upload audit has channel_id = None. Upload and read request host provides req.community; Blossom/NIP-98 auth URL host must agree. Blob CAS bytes may be operator-global shared storage; metadata, authorization, quotas, audit, and visibility are community-global. Media metadata/audit rows include community_id; if object keys stay SHA-addressed, any per-community policy lives outside the raw blob key. Upload/read authorization uses community context. Shared hash bytes are allowed only as dedup/storage optimization; metadata/errors must not reveal another community's private upload. Existing media URLs keep working for default community, but clients must now present read auth; there is no config flag that restores unauthenticated reads. Resolved: blob reads are authenticated and host/tenant-scoped, not public. Remaining gap, deferred: a read is not gated on the channel ACL of the message the blob was attached to, so relay membership plus a known hash is sufficient.
Git hosting / NIP-34 / object storage Smart HTTP at /git/{owner}/{repo} hydrates from S3 object pointers; NIP-34 repo announcements use d=repo-id; pointer key is repos/{owner}/{repo}/pointer; git push emits kind:30618. Git HTTP host gives req.community; NIP-98 URL and repo announcement community must agree. Community-global repo namespace and NIP-34 state; pack/manifests CAS objects may be operator-global if pointers are scoped. Pointer/name keys include community, e.g. repos/{community}/{owner}/{repo}/pointer; NIP-34 replaceable coords include community_id; any repo-name registry is (community_id, owner, repo) or (community_id, repo) per product rule. Clone/push/read policy resolves repo and branch protections only inside the host community. Git hook policy callback carries community and rejects mismatches. Existing clone URLs and repo ids work under the default community; object-store migration can move pointers under default prefix without changing git clients. Add tests for same owner/repo in two communities and push in A not advancing B pointer.
Mesh, agents, ACP/MCP, and CLI Agents/CLI connect to a relay URL and use WS/REST; mesh/pairing/presence/status events are regular signed relay events. The relay URL/host configured in the agent/CLI session selects community. Agent membership, persona/profile, presence, jobs, memory events, and mesh status are community-global unless a future operator mesh plane is explicitly separate. Any persisted agent profile/job/mesh status rows/events use community_id; Redis/presence/search keys follow the same community scoping. A portable key may join multiple communities, but memberships, DMs, profiles, jobs, and presence do not bleed across them. Existing BUZZ_RELAY_URL continues to select the one default community. Add CLI/ACP smoke tests against two hosts using same key with different memberships/profile.
Audit log and observability One hash-chain audit log records event/channel/auth/media actions; errors are sanitized before reaching clients. Every tenant-observable audit entry is labeled with req.community or inherited community from the object being acted on. Community-global audit chains; operator metrics/log aggregation may be platform-global only if tenant labels are bounded and access-controlled. audit_log key/sequence/head includes community_id; error/audit projection tables include community_id; uniqueness is (community_id, seq) and (community_id, hash) as appropriate. Audit reads verify only one community chain. Error strings must not include cross-community IDs, constraint names, or existence facts. Single-community audit verification still traverses one chain. Eva owns model edits here; infra lane must ensure media/git/token/search rows emit community-labeled audit entries.

Migration gates

Before multi-tenant mode is admitted, the implementation must have automated gates for these classes of mistakes:

  1. Every tenant-scoped table has community_id, RLS policy, and no unique/FK constraint that can be observed across tenants unless explicitly admitted as operator-global.
  2. Every direct lookup by event id, token hash, workflow id, approval token, repo pointer/name, media hash metadata, pubkey profile, or channel id also carries community context or first resolves the object under community.
  3. Every cache/search/pubsub/object-store key that can affect tenant-visible observations includes community context, except for deliberately shared CAS byte storage whose authorization metadata is community-scoped.
  4. Every externally reachable handler obtains TenantContext from host binding before reading request body data that can cause tenant effects.
  5. N=1 conformance tests prove existing clients do not need new tags, paths, event fields, CLI flags, or protocol messages to keep current behavior.