Multi-tenant: varias comunidades sobre una misma infraestructura
Multi-tenant: varias comunidades sobre una misma infraestructura
Hasta aquí el curso asumió el caso simple: un relay, un dominio, una comunidad — el despliegue por
defecto, el que montaste en
Despliegue en un VPS con Docker Compose.
Pero el esquema de base de datos de Buzz no es single-tenant con un parche multi-tenant encima:
la migración 0001_initial_schema.sql se llama, literalmente, “Buzz initial Postgres schema —
multi-tenant”, y community_id es una columna de primera clase en prácticamente cada tabla. El
despliegue de una sola comunidad es el caso degenerado de ese modelo, no un modo distinto.
El modelo: la URL es el selector
La regla de cara al cliente está en el README: “A Buzz community is the workspace a user reaches by URL.” En el despliegue de un solo relay que se envía hoy, la URL selecciona exactamente una comunidad. Un operador hospedado puede servir muchas comunidades detrás de muchos dominios o subdominios, y la regla no cambia: la URL es autoritativa sobre el workspace, y todo el estado bajo esa URL es local a esa comunidad.
Esto es la “fila cero” del documento de conformance (docs/multi-tenant-conformance.md):
req.community = resolve_host(connection.host)
Resuelta antes de que cualquier EVENT/REQ de WebSocket, handler REST, handler de medios,
transporte git, webhook, efecto de workflow, consulta de búsqueda o fan-out de pub/sub observe
datos de tenant. Tres consecuencias operativas:
- El host de la URL es el selector autoritativo. Nada que venga del cliente lo puede sobreescribir.
- Un host desconocido o no mapeado falla cerrado con un rechazo genérico. Nunca cae a un tenant por defecto.
- Un token NIP-98 o de API puede estrechar o autenticar la autoridad, pero jamás la puede
cambiar de comunidad. Un token cuyo sello de comunidad no coincide con
req.communityse rechaza.
flowchart TD
A["Cliente conecta a wss://equipo-a.example"] --> B["Header Host: equipo-a.example"]
B --> C["normalize_host en buzz-core"]
C --> D{"lookup en tabla communities por lower host"}
D -->|"encontrado"| E["TenantContext resuelto: community + host"]
D -->|"no mapeado"| F["404 generico, sin upgrade a WebSocket"]
D -->|"error de lookup"| F
E --> G["Upgrade a WebSocket con el tenant ya fijado"]
G --> H["Handlers: EVENT, REQ, REST, media, git, search, pubsub"]
El código del seam vive en crates/buzz-relay/src/tenant.rs, con la función bind_community, y se
llama desde crates/buzz-relay/src/router.rs:300 antes del upgrade a WebSocket. El comentario
del código lo dice sin adornos: “no frame is ever read on an unbound connection”. Cuando falla, la
respuesta es un 404 con el texto relay: no community is configured for this host,
deliberadamente indistinguible entre “host no mapeado” y “la base de datos no respondió”, y sin eco
del host: así nadie puede usar el relay como oráculo para enumerar comunidades.
Normalización del host: la trampa silenciosa
buzz_core::tenant::normalize_host es la única regla de normalización, compartida por los dos
lados de la cerca: la columna communities.host se guarda ya normalizada y la resolución normaliza
el Host entrante con la misma función antes de buscar. Las reglas: ASCII-lowercase, se quita un
único punto final del FQDN, se quitan los puertos por defecto :80 y :443 —un puerto no-default
se conserva, porque un despliegue puede servir comunidades distintas en puertos distintos del
mismo nombre— y se recorta el whitespace. Los tests del crate lo fijan: relay.example, Relay.Example, RELAY.EXAMPLE, relay.example.,
relay.example:443, relay.example:80 y Relay.Example.:443 colapsan todos al mismo tenant.
relay.example:8443 y relay.example:3000 no colapsan: son selectores distintos. Los
literales IPv6 quedan intactos: [::1]:443 colapsa a [::1].
Un host vacío resuelve a cadena vacía y bind_community corta antes de consultar la base: el
esquema no prohíbe una fila con host = '', así que sin esa guarda una petición con Host ausente
se ligaría en silencio a una comunidad mal configurada.
La consecuencia para el proxy inverso
El relay lee el header Host estándar (axum::http::header::HOST en router.rs:265). No lee
X-Forwarded-Host: si tu proxy reescribe el Host hacia el upstream, todas las peticiones llegan
con el host equivocado y fallan cerradas con el 404 genérico. El Caddyfile del bundle de deploy/compose/ es un {$BUZZ_DOMAIN} { reverse_proxy relay:3000 }
de una línea y preserva el Host por defecto. Si usas nginx, HAProxy o un ingress de Kubernetes,
verifica que el Host llegue intacto: es el fallo de configuración número uno de este modelo.
El bootstrap: de dónde sale la primera comunidad
En el arranque, crates/buzz-relay/src/main.rs:260 deriva el host desde RELAY_URL —la variable del
relay, no la BUZZ_RELAY_URL del harness ACP— usando
relay_url_authority y llama a db.ensure_configured_community(&host) (main.rs:274), que es
idempotente. El
comentario del código explica el porqué: sembrar la comunidad del despliegue antes de cualquier
backfill de membresía, “so the owner lands in exactly the community that live requests for this host
will resolve to”. Si el host derivado queda vacío y BUZZ_REQUIRE_RELAY_MEMBERSHIP está activo, el
arranque falla en duro; si la membresía no es obligatoria, registra un warning y sigue sin sembrar.
Aquí está el segundo error clásico de operación: RELAY_URL y el dominio público tienen que
coincidir. En deploy/compose/.env.example conviven BUZZ_DOMAIN, RELAY_URL,
BUZZ_MEDIA_BASE_URL, BUZZ_MEDIA_SERVER_DOMAIN y BUZZ_CORS_ORIGINS, todos apuntando al mismo
nombre. Si RELAY_URL apunta a buzz.example.com pero los clientes llegan a relay.example.com,
el arranque siembra la comunidad del primero y las conexiones reales se rechazan con 404: el relay
está sano, la base está sana, y nadie puede conectarse.
El CLI usa el mismo mecanismo: buzz-admin resuelve su tenant desde RELAY_URL (por defecto
ws://localhost:3000) con la misma relay_url_authority, y si el host no está mapeado falla con
un error explícito en vez de operar sobre un tenant por defecto. Es single-community por
invocación; no existe un barrido cross-community.
Qué queda scopeado por comunidad
El punto central del modelo: la infraestructura compartida es un detalle de implementación, no un workspace global. Un Postgres, un Redis y un bucket S3 compartidos no producen un espacio de nombres compartido, y un proceso relay es cómputo sin estado: no posee datos de ninguna comunidad, sirve a cualquiera, y N procesos comparten el almacén.
flowchart LR
subgraph Hosts["Hosts publicos"]
H1["equipo-a.example"]
H2["equipo-b.example"]
end
subgraph Relay["Procesos relay sin estado"]
R1["relay pod 1"]
R2["relay pod 2"]
end
subgraph Infra["Infraestructura compartida"]
PG["Postgres con community_id en cada fila"]
RD["Redis con claves buzz:community:..."]
S3["Object storage con punteros por comunidad"]
end
H1 --> R1
H1 --> R2
H2 --> R1
H2 --> R2
R1 --> PG & RD & S3
R2 --> PG & RD & S3
| Superficie | Cómo queda scopeada | Evidencia en el repositorio |
|---|---|---|
| Registro de tenants | communities(id, host, signing_key, created_at), UNIQUE (lower(host)) | migrations/0001_initial_schema.sql:53-61 |
| Log de eventos | events con PK (community_id, created_at, id), particionada por rango de created_at | 0001:191-235 |
| Índice de lookup directo | idx_events_community_id sobre (community_id, id, created_at DESC) | 0001:257 |
| Canales | PK (community_id, id); el mismo UUID puede existir en dos comunidades | 0001:97 |
| Inmutabilidad de canal | Trigger trg_channels_community_id_immutable que lanza excepción en cualquier UPDATE que cambie community_id | 0001:112-127 |
| Usuarios y NIP-05 | PK (community_id, pubkey); único (community_id, lower(nip05_handle)) | 0001:155-180 |
| Auditoría | audit_log con PK (community_id, seq) y único (community_id, hash) | 0001:606-619 |
| Cadenas de auditoría (cap. 10) | Una cadena hash independiente por comunidad, serializada con pg_advisory_lock | crates/buzz-audit/src/service.rs:25-108 |
| Búsqueda FTS | Postgres FTS con community_id como primer predicado de toda consulta | crates/buzz-search/src/lib.rs:3-50 |
| Claves de pub/sub | buzz:{community}:channel:{channel_id}, buzz:{community}:global | crates/buzz-pubsub/src/topic.rs:44-48 |
| Presencia, caché y conexiones | buzz:{community}:presence:{pubkey}, buzz:{community}:cache-invalidate, buzz:{community}:conn-control | presence.rs:123, cache_invalidation.rs:193, conn_control.rs:178 |
| Punteros git (cap. 9) | repos/{community}/{owner}/{repo}/pointer | crates/buzz-relay/src/api/git/manifest.rs:174-183 |
Los bytes crudos de un blob de medios pueden seguir siendo direccionados por contenido y deduplicados a nivel de operador. Lo que no es compartido son los descriptores, la autorización de subida, las cuotas, las filas de auditoría y cualquier política de lectura: un hash compartido puede ser una optimización de almacenamiento, pero los metadatos y los errores no pueden revelar la subida privada de otra comunidad.
Qué es deliberadamente operator-global
No todo puede ser por comunidad. El esquema lleva un registro explícito de tablas operator-global, y es una tabla de la base de datos, no una lista hardcodeada en el linter:
INSERT INTO _operator_global_tables (table_name, reason) VALUES
('communities', 'the tenant registry itself; id IS the community key'),
('rate_limit_violations', 'deployment abuse/health; never tenant-observable; community_id is an attribution label only'),
('_operator_global_tables', 'the registry table itself');
El linter de buzz-db reconoce además las tablas del gateway de push (push_gateway_challenges,
push_gateway_installations, push_gateway_delegations, push_gateway_endpoint_quotas,
push_gateway_delivery_auth_replays, push_gateway_delivery_request_replays), product_feedback y
replica_heartbeat. Cualquier tabla que no esté en ese registro debe llevar
community_id NOT NULL y encabezar sus uniques con esa columna.
La cerca: el h tag es entrada adversaria
El documento formal docs/multi-tenant-relay.md es explícito sobre el peligro que este diseño
tiene que cerrar: el diputado confundido (Hardy, 1988). El relay tiene autoridad amplia sobre
una base compartida; si actúa bajo un nombre que suministra el cliente, el cliente escapa de su
comunidad. La defensa es disciplina de capacidades: la autoridad se liga al objeto resuelto,
nunca a un tag que trae el cliente.
sequenceDiagram
participant C as Cliente en host A
participant R as Relay
participant DB as Postgres
C->>R: Conexion WebSocket, Host: a.example
R->>DB: resolve_host normalizado
DB-->>R: community A
Note over R: TenantContext fijado antes del primer frame
C->>R: EVENT con h tag apuntando a canal de comunidad B
R->>DB: resolve del canal bajo el mismo snapshot
DB-->>R: el canal pertenece a la comunidad B
R-->>C: rechazo generico, restricted
Note over R: nunca se actua como B, ni siquiera en la ruta de duplicado
Dos reglas gobiernan la composición:
- Evento con
htag: se exige queresolve(h)sea igual aresolve_host(host). Un host de A presentando un evento de un canal de B se rechaza cerrado; no se actúa como B. - Evento sin
htag (perfiles, DMs, long-form, listas, read-state): se guardacommunity_id = resolve_host(host)conchannel_id = NULL. La admisión que decide quién puede hacerlo es la de Membresía, roles y moderación, ahora con clave(community_id, pubkey).
Y hay una tercera regla que suele pasar desapercibida: la ruta del duplicado también está
cercada. Si un host de A pudiera provocar un INSERT ... ON CONFLICT DO NOTHING contra un id de
canal de B, el resultado “duplicado” sería un oráculo de existencia cross-tenant; el fence corta
antes de la búsqueda de conflicto.
En el nivel de tipos, TenantContext no tiene Default ni Deserialize, y CommunityId no tiene
From<Uuid> público para parsear entrada de cliente. Pero el propio código lo declara con
honestidad: “This is a lint-and-review fence, not a compiler fence.” Ambos constructores son
públicos porque la resolución vive en otro crate: el tipo elimina el camino accidental, y el
deliberado lo cierran el lint y la revisión.
El plano de operador
Las comunidades no se crean con DDL: crear una comunidad es un INSERT. El plano que lo hace está
fuera del plano de datos de Nostr y tiene su propia autenticación, gobernada por dos variables
(declaradas en crates/buzz-relay/src/config.rs:186-205 y leídas en config.rs:656-686):
| Variable | Qué hace |
|---|---|
RELAY_OPERATOR_PUBKEYS | Lista separada por comas de pubkeys hex de 64 caracteres autorizadas a usar /operator/communities. Vacío (el default) desactiva el aprovisionamiento por completo: falla cerrado. Una entrada inválida es un error de configuración en el arranque, no una entrada saltada en silencio. |
RELAY_OPERATOR_API_ORIGIN | Origen HTTP canónico del API de operador. Cada tag u de NIP-98 de operador se verifica contra este origen, con independencia del header Host entrante y del registro de tenants. Es obligatorio cuando RELAY_OPERATOR_PUBKEYS no está vacío. |
El operador es distinto de RELAY_OWNER_PUBKEY: el owner es un rol dentro de la comunidad del
despliegue, mientras que el operador cruza tenants —crea comunidades y hace bootstrap de owners
iniciales— sin fila de membresía implícita en ninguna. Rutas en router.rs:76-93:
| Método y ruta | Handler |
|---|---|
GET /operator/communities | list_owned_communities, con query owner_pubkey |
POST /operator/communities | provision_community |
GET /operator/communities/availability | community_availability, con query host |
POST /operator/communities/archive | archive_community |
POST /operator/communities/unarchive | unarchive_community |
POST /operator/communities/transfer | transfer_community |
Todas exigen NIP-98 sin excepción —“always require NIP-98; no X-Pubkey dev fallback”— y usan su
propio namespace de anti-replay, operator-management. El cuerpo de aprovisionamiento es
ProvisionCommunityRequest:
{
"host": "equipo-b.example",
"initial_owner_pubkey": "0123...64hex",
"create_only": true
}
create_only: true crea host y owner de forma atómica y rechaza un host existente en vez de
converger o rotar la propiedad; sin ese flag la petición es idempotente y la respuesta trae
status: "created" o status: "existed". El host del cuerpo pasa por normalize_candidate_host, que rechaza cadena vacía, hosts de más de
MAX_HOST_LEN bytes, caracteres de control o whitespace, y cualquier cosa que contenga /, ?,
# o @: el campo debe ser una autoridad desnuda sin esquema, ruta, query ni userinfo. Recién
después aplica la misma normalize_host de buzz-core. transfer_community toma community_id,
new_owner_pubkey y expected_owner_pubkey; el último es el guardia de concurrencia optimista
para que dos transferencias en carrera no se pisen.
Aparte de todo esto, BUZZ_ADMIN_HOST define una autoridad exacta y separada: si el Host entrante
coincide con ella, el relay corta antes de la ruta pública —no sirve el bundle web, ni NIP-11, ni
hace upgrade a WebSocket— y expone solo rutas de lectura (/reports, /feedback) validando además
el header Origin. Sin esa variable configurada, el API de admin devuelve 404 completo.
NIP-11: la excepción que confirma la regla
El documento de información del relay en / es no autenticado y no tiene tenant por
construcción. El modelo formal lo trata como un canal aparte, la clase C2.4, cerrado por una cerca
de tipos: la función que lo construye consume solo configuración estática del relay, sin handle de
base de datos, contexto de tenant ni servicio de auditoría.
Operativamente hay un matiz que importa: NIP-11 falla abierto, no cerrado. Un host no mapeado
igual recibe el documento, simplemente sin los campos scopeados por host como el icon del
workspace. El comentario en router.rs explica la razón: “so the doc cannot leak which hosts are
mapped”. Si NIP-11 fallara cerrado, la respuesta misma sería un enumerador de comunidades.
La regla que el documento formal impone hacia el futuro: supported_nips está bien, un
total_events sería una fuga cross-tenant. Cualquier endpoint no autenticado nuevo —métricas de
NIP-66, sondas de salud que expongan contadores— cae bajo la misma clase por defecto.
Conformance: cómo se valida el aislamiento
Hay tres capas de validación, y conviene no confundirlas:
flowchart TD
A["docs/multi-tenant-conformance.md"] --> B["Checklist superficie por superficie: fuente del tenant, scope de DB, efectos de auth y fan-out, compatibilidad N igual 1"]
C["docs/spec/MultiTenantRelay.tla"] --> D["TLC: no interferencia como invariante de flujo de etiquetas"]
E["docs/spec/MultiTenantAuth.spthy"] --> F["Tamarin: solidez de autorizacion bajo adversario Dolev-Yao"]
G["crate buzz-conformance"] --> H["Replay de trazas reales del relay contra el modelo"]
I["Lints de migracion en buzz-db"] --> J["community_id NOT NULL, uniques compuestos, inmutabilidad de canal"]
La capa 1: el checklist
docs/multi-tenant-conformance.md es la tabla fuente-contra-modelo. Cada fila es una superficie
—NIP-11, tokens de API, membresía, usuarios y NIP-05, eventos sin canal y DMs, canales, workflows,
búsqueda, pub/sub, medios, git, mesh y agentes, auditoría— con siete columnas: comportamiento
observable de hoy, fuente del tenant, community-global contra operator-global, scope requerido en DB
e índices, efectos en auth/fan-out/búsqueda, compatibilidad con una sola comunidad, y la decisión o
test abierto. Cierra con cinco gates de migración que deben estar automatizados antes de admitir
el modo multi-tenant:
- Toda tabla scopeada tiene
community_id, política RLS y ninguna restricción unique o FK observable entre tenants, salvo admisión explícita como operator-global. - Todo lookup directo por id de evento, hash de token, id de workflow, token de aprobación, puntero de repo, metadatos de medios, perfil por pubkey o id de canal lleva contexto de comunidad, o primero resuelve el objeto bajo la comunidad.
- Toda clave de caché, búsqueda, pub/sub u object storage que afecte observaciones de tenant incluye contexto de comunidad, salvo el almacenamiento CAS deliberadamente compartido.
- Todo handler alcanzable desde fuera obtiene su
TenantContextdesde el binding de host antes de leer datos del cuerpo que puedan causar efectos de tenant. - Los tests con N = 1 prueban que los clientes existentes no necesitan tags, rutas, campos de evento, flags de CLI ni mensajes de protocolo nuevos.
Sobre el punto 1 conviene una advertencia honesta: las migraciones incluidas en el repositorio
no crean políticas de row-level security. RLS aparece en el documento formal como axioma
A-RLS-1..5, una obligación admitida por despliegue mediante aserciones de arranque o CI, no algo
que la migración 0001 te deja hecho. En el esquema hoy están las claves compuestas, los índices
que encabezan con community_id, el trigger de inmutabilidad y los lints. RLS es el respaldo para
que un predicado de aplicación olvidado devuelva cero filas en vez de todas; si lo necesitas, es
trabajo del operador.
La capa 2: los modelos mecanizados
docs/spec/MultiTenantRelay.tla codifica el aislamiento como no interferencia, no como un
WHERE community_id = $1. La diferencia importa: un invariante de igualdad de filas no dice nada
sobre timing, errores, colisiones de unicidad, reconstrucción de proyecciones o la compuerta de
auth. La codificación es un invariante de flujo de etiquetas: cada elemento de estado lleva la
etiqueta de la comunidad de la que se originó, y la propiedad es “ningún valor de etiqueta alta
fluye hacia una observación baja”.
En el harness finito comprometido, TLC completa exhaustivamente: 472.530.528 estados generados,
16.226.016 distintos, cero en cola, profundidad 13. Trece mutaciones (M1 a M13) están confirmadas en
rojo: el lookup directo por id sin scope, la etiqueta de error ensanchada, la clave de conflicto
global sin community_id, el host no mapeado cayendo a un tenant por defecto, la ruta del duplicado
sin cerca, la admisión re-clavada a “admitido en cualquier comunidad”, y las de AUTH abierto,
creación de canal y las dos lecturas sin #h.
docs/spec/MultiTenantAuth.spthy prueba la solidez de autorización bajo adversario Dolev-Yao: 32
lemas en verde con Tamarin 1.12.0 y Maude 3.5.1 en unos 12 segundos. Un token sellado para A nunca
autoriza en B; un token filtrado sí autoriza dentro de su propia comunidad —el radio de explosión no
es cero y el documento no finge lo contrario— pero nunca en otra; la clave de firma de B nunca
admite una pubkey en A por NIP-43; y una autoregistración en comunidad abierta se sella con la
comunidad resuelta por host.
La capa 3: el crate buzz-conformance
Aquí está la parte que un operador puede ejecutar, gobernada por una frase: “Don’t ask ‘did the model pass.’ Ask ‘did the running code emit a trace the model accepts.’”
crates/buzz-conformance define un esquema de trazas y un checker de replay independiente. La
regla de independencia está escrita en el propio Cargo.toml y es tajante: el crate no depende de
ningún crate de producción de Buzz —ni buzz-db, ni buzz-relay, ni buzz-pubsub, ni
buzz-auth, ni buzz-search, ni buzz-audit— y lleva su propio newtype opaco CommunityLabel en
vez de reusar buzz_core::CommunityId: así no perfora la cerca de “sin Serde, sin From<Uuid>” de
producción, y un bug en los tipos de producción no se convierte mecánicamente en un bug del checker.
El checker reimplementa la relación de transición del spec desde cero.
El relay emite un TraceStep por decisión en el borde de ingesta/auth/lectura:
{
"schema_version": 1,
"action": { "type": "write_insert", "msg_id": "d34db33fcafef00d", "channel": "cafe0000-0000-0000-0000-000000000010", "claimed_community": "aaaa0000-0000-0000-0000-000000000001" },
"state_after": { "resolved_community": "aaaa0000-0000-0000-0000-000000000001", "bound_host": "a.example.test", "actor": "0123456789abcdef" }
}
El estado es proyectado, no crudo: lleva la comunidad resuelta por el servidor, la etiqueta
opaca del host y los primeros 16 hex de la pubkey autenticada, pero no el h tag del cliente, el id
de evento, el payload, bytes del Host, claves, tokens ni firmas. La regla clave:
claimed_community se registra separado de resolved_community —si discrepan gana la resuelta—
y la traza muestra ambas para que la mutación “autorizar según lo declarado” pueda morder.
Las acciones de TraceAction cubren el seam de escritura (write_insert, write_insert_global,
write_duplicate), el de lectura (auth_check, read_message_rows, read_by_id_rows,
read_host_feed_rows), el de error (sanitized_error, con alfabeto cerrado restricted /
invalid / server_error) y el testigo de cobertura impl_bug. row_communities es un Vec
sin deduplicar: el checker tiene que ver cada etiqueta filtrada, no el conjunto.
El checker devuelve tres clases de fallo: IllegalTransition cuando la acción trazada no está
permitida desde el estado del modelo —lo que muerde cuando un auth_check con veredicto allow
lleva una comunidad declarada ajena—; NonInterference cuando row_communities incluye una
comunidad distinta de la resuelta, que es la fuga de filas ajenas; y CoverageBreach ante una traza
vacía, una acción crítica requerida que nunca apareció, o un impl_bug porque un seam crítico salió
sin emitir nada. La cobertura es load-bearing: sin ella, esto sería logging decorativo.
El módulo emisor del lado del relay vive en crates/buzz-relay/src/conformance/ (mod.rs con el
EmitGuard y tracers.rs con los tracers), y el consumidor independiente en el crate
buzz-conformance (src/lib.rs, src/checker.rs, src/transitions.rs).
Hay cuatro fixtures JSONL en crates/buzz-conformance/tests/fixtures/: good.jsonl que pasa,
bad_host_channel_mismatch.jsonl que produce IllegalTransition, bad_coverage_breach.jsonl que
produce CoverageBreach, y bad_foreign_row_leak.jsonl con una fila bbbb... bajo un tenant
resuelto aaaa.... El test reconstruye cada fixture desde Rust tipado, verifica que el archivo
coincida byte a byte y recién entonces lo reproduce: un cambio de esquema obliga a actualizarlos.
cargo test -p buzz-conformance --lib # 9 tests de esquema y checker
cargo test -p buzz-conformance --test replay_fixtures # 6 tests de fixtures
cargo test -p buzz-relay --lib conformance:: # tests del EmitGuard y los tracers
# Refrescar fixtures a proposito tras un cambio de esquema
BUZZ_CONFORMANCE_UPDATE=1 cargo test -p buzz-conformance --test replay_fixtures
# En el Justfile, enganchado al job de tests unitarios junto al lint de scoping de tenant
cargo nextest run -p buzz-conformance
cargo nextest run -p buzz-db --lib
El comentario del Justfile es directo: sin ese último gate, “un archivo perdido en migrations/ o
un lint roto se publica en verde”.
Lo que el gate NO atrapa
crates/buzz-conformance/LIMITS.md existe para que nadie lea de más en una corrida verde. Empieza
con “The runtime conformance harness is not a proof” y declara fuera de alcance:
- Fugas de la capa de DB que la proyección no lee. El seam de lectura está pendiente: el documento dice literalmente que “the read-seam half of the gate is not yet armed”, a la espera de la decisión de diseño sobre cómo calcular la etiqueta por fila.
- Fugas cross-pod. El harness traza un proceso; una fuga multi-pod aparece solo en el pod que la observa. Propiedades acotadas por tiempo: el spec es sin tiempo y el gate también.
- Fan-out de pub/sub. No es una acción del spec; una fuga ahí aparece en la traza de ingesta o lectura del receptor, no en el emit del publicador.
- Cerca de tipos y bugs del spec. Que
CommunityIdno tengaFrom<Uuid>lo garantiza el compilador, no el gate. Y el checker reimplementa el spec: si el spec está mal, ambos pasan.
Y el detalle operativo que más importa: en producción el tracer por defecto es NoopTracer. El gate
es solo observación, no realimenta la decisión; apagarlo no cambia el comportamiento del relay,
solo pierdes la señal. Además, la brecha de cobertura solo puede dispararse en rutas donde el
harness fue armado: un endpoint nuevo que toque el borde de tenant y no arme su EmitGuard deja el
gate ciego, y eso lo enforcea la revisión de código, no el harness.
Riesgos de aislamiento que quedan declarados
El documento formal enumera lo que no promete, y esa lista es tan operativa como la de garantías.
Clase C1: canales físicos acotados por ancho de banda. Buffer cache, autovacuum, estadísticas del planner, throughput del borde derecho de las particiones y latencia de cola del pool de conexiones son compartidos, y un co-tenant los puede medir como timing. El documento es explícito: “We do not claim timing non-interference.”
Clase C2: canales lógicos, cada uno cerrado. El oráculo de existencia de ids de evento, cerrado
por la unicidad compuesta (community_id, ..., id): una escritura de B contra un id que A ya tiene
obtiene una clave fresca, no un conflicto. La superficie de errores de constraint, cerrada por un
alfabeto sanitizado de nueve prefijos alcanzables desde NIP-01: auth-required, restricted,
invalid, duplicate, pow, rate-limited, blocked, error, frame-too-large. La ruta de
reconstrucción de proyecciones, que toca eventos de todas las comunidades pero solo escribe tablas
del lado servidor y nunca sirve filas. Y NIP-11, cerrada por la cerca de tipos.
Clase C3: escrituras históricas tras revocación. La compuerta de admisión gobierna la capacidad actual. Revocar a un miembro se la quita, pero no re-etiqueta ni borra las filas que escribió mientras estaba admitido: “We do not claim historical writes are revoked when a member is revoked.” La redacción retroactiva es una superficie de ciclo de vida de datos del operador, fuera del modelo de aislamiento.
Fuga por encima de la interfaz. El límite de la prueba es la interfaz observacional del relay.
Si un cliente multi-tenant, un nevent NIP-19 compartido o un log filtrado expone ids de eventos de
la comunidad A a un usuario que también es miembro de B, ese usuario tiene un id de A fuera de banda
y puede sondear el oráculo de existencia desde una conexión de B. El índice compuesto hace que el
sondeo no revele nada, pero cerrarlo con un índice más débil es obligación del cliente.
Alta disponibilidad: NIP-98 y el seen-set compartido
La frescura del mint NIP-98 —que viste en
Identidad y autenticación— se apoya en dos
chequeos: la ventana de ±60 segundos (TIMESTAMP_TOLERANCE_SECS = 60 en
crates/buzz-auth/src/nip98.rs:32, aplicada en :81-83) y un seen-set por id de evento invocado
desde crates/buzz-relay/src/api/bridge.rs::check_nip98_replay (bridge.rs:136-156).
Aquí es donde conviene desconfiar de la documentación de diseño: el documento formal describe el
seen-set in-process como una obligación pendiente del operador en HA, pero el código ya no usa
uno. AppState instancia un guardia respaldado por Redis y no hay otra implementación cableada
(crates/buzz-relay/src/state.rs:803-804):
let nip98_replay: Arc<dyn Nip98ReplayGuard> =
Arc::new(RedisNip98ReplayGuard::new(redis_pool.clone()));
crates/buzz-pubsub/src/nip98_replay.rs lo describe sin ambigüedad: cada try_mark emite un solo
SET buzz:{community}:nip98:{event_id_hex} 1 NX EX <ttl>. El NX es la inserción atómica
“set-if-absent” y el OK de Redis en la primera reclamación es la prueba de frescura; una
reclamación posterior dentro del TTL devuelve nil, que el llamador traduce a rechazo por replay.
El comentario del propio check_nip98_replay cierra el punto: “The correctness boundary is the
shared, community-scoped Redis seen-set on AppState, not process-local memory.”
Cuatro consecuencias operativas concretas:
- La clave lleva la comunidad resuelta por el servidor, no el host que declara el cliente
(
nip98_replay_keyencrates/buzz-auth/src/nip98_replay.rs:114-119). El mismo id de evento reclamado en dos comunidades usa dos seen-sets distintos: eso es deliberado y hay un test que lo fija. - El TTL tiene piso y techo.
DEFAULT_REPLAY_TTL_SECS = 120(dos veces la ventana) es el piso;MAX_REPLAY_TTL_SECS = 3600el techo. Un valor por debajo del piso se eleva y uno por encima del techo se recorta, así que un llamador con un bug no puede dejar la ventana descubierta ni fijar una entrada por tiempo implausible. - Falla cerrado. Si el pool de Redis no da conexión,
try_markdevuelveAuthError::Internaly la petición se rechaza: sin la prueba compartida, un worker sin estado no puede admitir la petición NIP-98 con seguridad. - Es cross-pod por construcción. El repositorio trae dos tests
#[ignore = "requires Redis"]enbridge.rsque lo prueban: uno reclama el id desde el “pod A” y verifica que el “pod B” lo rechace en la misma comunidad y lo acepte en otra; el segundo es la regresión de mismo pod, que existe justamente porque el caché moka en proceso fue reemplazado por este seen-set.
Por eso los ejemplos de HA del repositorio con replicaCount: 3
(deploy/charts/buzz/examples/argocd-app.yaml:32 y
deploy/charts/buzz/examples/flux-helmrelease.yaml:35) ya no arrastran ese hueco. Lo que sí impone
la multi-réplica es la dependencia dura de Redis: templates/_validate.tpl:16-17 hace fallar el
render si subes réplicas sin redis.enabled=true, sin externalRedis.url y sin un
secrets.existingSecret que traiga REDIS_URL. Redis deja de ser solo el bus de fan-out y pasa a
ser también la frontera de corrección del anti-replay: si se cae, no hay “modo degradado sin
límites”, hay rechazo.
Métricas por comunidad y su costo
Con muchos tenants, la observabilidad es un problema de presupuesto antes que de datos.
BUZZ_USAGE_METRICS_PER_COMMUNITY acepta all (default), que emite series de gauge por comunidad
para cada comunidad, u off, que las suprime y deja solo los totales de flota.
El comentario del código explica el porqué con números: con unas 25 combinaciones de etiquetas de
gauge por comunidad, un relay con miles de comunidades incurriría en costos mensuales de cinco
cifras si cada una recibiera siempre el juego completo de series. Los totales de flota
(buzz_total_*) se emiten siempre. Hay un modo top:<k> planificado como fast-follow; un valor
desconocido produce un warning y cae a all. Los detalles de scraping y alertas los cubre
Observabilidad: métricas, logs y salud del relay.
Checklist operativo
Si vas a servir más de una comunidad desde una infraestructura, en orden:
- Verifica que el proxy inverso preserve el header
Hostintacto hacia el upstream. - Confirma que
RELAY_URLderive la misma autoridad que usan los clientes, con puerto no-default incluido si corresponde. - Define
RELAY_OPERATOR_PUBKEYSyRELAY_OPERATOR_API_ORIGINjuntos; el segundo es obligatorio si el primero no está vacío. - Aprovisiona cada host con
POST /operator/communitiesfirmado con NIP-98, usandocreate_only: truecuando un host ya existente deba ser un error, y consulta antesGET /operator/communities/availability. - Si corres más de una réplica, provee Redis de verdad: el seen-set NIP-98 y el rate limiter
compartido viven ahí, y el chart falla el render si subes réplicas sin
redis.enabled=true,externalRedis.urlo unREDIS_URLdentro desecrets.existingSecret. - Mantén
cargo nextest run -p buzz-db --libycargo nextest run -p buzz-conformanceen verde en cada PR que toquemigrations/o el borde de tenant. - Decide el modo de
BUZZ_USAGE_METRICS_PER_COMMUNITYantes de que la factura te lo decida. - Si tu perfil de riesgo lo exige, implementa las políticas RLS que el documento formal admite como axioma: el esquema incluido no las trae, y la revocación de un miembro no borra sus escrituras históricas.
Resumen
- Una comunidad es el workspace que un usuario alcanza por URL. Un relay por defecto sirve una; un operador hospedado sirve muchas detrás de muchos dominios, y la regla no cambia.
- La comunidad se deriva del host con
resolve_host.bind_communityse llama enrouter.rs:300antes del upgrade a WebSocket; un host no mapeado o un fallo de lookup devuelven el mismo 404 genérico. Nunca hay tenant por defecto. normalize_hostes la única regla de normalización: lowercase, sin punto final, sin:80ni:443, puertos no-default conservados. El relay lee el headerHost, noX-Forwarded-Host: un proxy que lo reescriba rompe todo el binding.- Queda scopeado por
community_id:eventscon PK(community_id, created_at, id), canales con PK(community_id, id)y trigger de inmutabilidad, usuarios y NIP-05,audit_logcon una cadena hash independiente por comunidad, la búsqueda FTS, las claves de Redisbuzz:{community}:...y los punteros gitrepos/{community}/{owner}/{repo}/pointer. Lo deliberadamente operator-global vive en la tabla_operator_global_tables, no en una lista del linter. - El
htag es entrada adversaria: pista de ruteo, nunca el punto de commit del tenant. El host gana siempre, incluso sobre el sello de un token NIP-98, y la cerca cubre también la ruta del duplicado para que no sea un oráculo de existencia. - El plano de operador es aparte:
RELAY_OPERATOR_PUBKEYSvacío desactiva el aprovisionamiento por completo yRELAY_OPERATOR_API_ORIGINfija el origen contra el que se verifica cadaude NIP-98 de operador. NIP-11, en cambio, falla abierto a propósito para no enumerar hosts. - La validación tiene tres capas: el checklist de
docs/multi-tenant-conformance.mdcon sus cinco gates, los modelos mecanizados en TLA+ y Tamarin, y el cratebuzz-conformance, que reproduce trazas reales contra una reimplementación independiente del spec y falla porIllegalTransition,NonInterferenceoCoverageBreach. SuLIMITS.mdimporta tanto como sus tests: el seam de lectura aún no está armado, no ve fugas cross-pod ni de fan-out, y en producción el tracer por defecto esNoopTracer. - Riesgos declarados fuera de la garantía: timing y recursos físicos compartidos, escrituras históricas tras una revocación, y fugas por encima de la interfaz.
- El anti-replay NIP-98 ya es compartido:
AppStatemontaRedisNip98ReplayGuard, que haceSET buzz:{community}:nip98:{id} NX EXcon piso de 120 s y techo de 3600 s, falla cerrado ante un Redis inalcanzable y está probado cross-pod. El documento formal, que aún describe un seen-set en proceso, va por detrás del código en este punto.