Membresía, roles y moderación de la comunidad

Por: Artiko
buzznostrnip-43nip-29moderacionmembresiabuzz-admin

Membresía, roles y moderación de la comunidad

En el capítulo 7 cerramos la puerta de entrada: toda conexión WebSocket presenta un evento kind:22242 firmado y el relay resuelve un AuthContext. Autenticarse, sin embargo, solo demuestra quién eres. Este capítulo trata de lo otro: qué puedes hacer una vez dentro. Autenticación es NIP-42; membresía de relay es NIP-43; membresía de canal es NIP-29; y la moderación de la comunidad es una cuarta capa sobre las tres.

Los dos planos de membresía

Hay exactamente dos tablas que deciden acceso, y responden preguntas diferentes.

flowchart TD
    auth["NIP-42 AUTH<br/>evento kind 22242 firmado"] --> gate1{"BUZZ_REQUIRE_RELAY_MEMBERSHIP"}
    gate1 -->|"false"| open["Relay abierto:<br/>cualquier pubkey autenticada entra"]
    gate1 -->|"true"| members{"¿fila en relay_members<br/>para esta comunidad?"}
    members -->|"no"| reject["Conexión rechazada"]
    members -->|"sí"| inside["Dentro de la comunidad"]
    open --> inside
    inside --> chan{"¿membresía en channel_members<br/>del canal objetivo?"}
    chan -->|"no"| restricted["restricted: not a channel member"]
    chan -->|"sí"| rw["Lectura y escritura en el canal"]
  • relay_members — quién pertenece a la comunidad. Es la compuerta NIP-43. Roles posibles: owner, admin, member (restricción CHECK explícita en migrations/0001_initial_schema.sql).
  • channel_members — quién pertenece a cada canal. Es la compuerta NIP-29. Roles posibles: owner, admin, member, guest, bot (tipo member_role en la misma migración).

SECURITY.md es tajante sobre el segundo plano: “la membresía de canal es el único mecanismo de control de acceso. No hay listas ACL separadas ni taxonomías de capacidades”. Si un principal es miembro de un canal puede leer y escribir en él; si no lo es, el relay lo rechaza aunque esté perfectamente autenticado. Ambas tablas llevan community_id en su clave primaria, así que en un despliegue multi-comunidad la misma pubkey puede ser admin en una y no existir en la otra — ver el capítulo 11.

Membresía de relay: NIP-43

Qué la activa

La compuerta de membresía se enciende con una variable, revisada en el capítulo 4:

BUZZ_REQUIRE_RELAY_MEMBERSHIP=true
RELAY_OWNER_PUBKEY=<64-char-hex-pubkey>

Con esto, cada conexión autenticada se verifica contra relay_members: solo las pubkeys con una fila para la comunidad derivada del host pueden usar esa comunidad.

El dueño no se agrega a mano: el relay bootstrapea automáticamente al dueño desde RELAY_OWNER_PUBKEY en el arranque. Por eso RELAY_OWNER_PUBKEY es hex de 64 caracteres y no lleva prefijo BUZZ_ — es la única forma de fijar el rol owner. El arranque trata además explícitamente el caso de exigir membresía sin dueño configurado (crates/buzz-relay/src/main.rs): no es un accidente silencioso.

Hay una consecuencia de protocolo: el documento NIP-11 anuncia NIP-43 condicionalmente. Solo se agrega a supported_nips cuando el relay realmente aplica membresía y tiene una clave de firma estable (BUZZ_RELAY_PRIVATE_KEY). Ambas condiciones son necesarias para que los kinds 13534, 8000 y 8001 sean verificables por los clientes.

La tabla real

CREATE TABLE relay_members (
    community_id UUID NOT NULL REFERENCES communities(id),
    pubkey      TEXT NOT NULL,
    role        TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
    added_by    TEXT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (community_id, pubkey)
);

La pubkey se guarda como hex TEXT, no como BYTEA — a diferencia de channel_members y pubkey_allowlist, que sí usan bytes. Es la forma de cable sin transformar.

El CLI de administración: buzz-admin

buzz-admin es el CLI de operador. Viaja dentro de la imagen Docker del relay, en /usr/local/bin/buzz-admin, junto a buzz-relay y buzz-pair-relay.

Los siete subcomandos reales

Verificados en crates/buzz-admin/src/main.rs:

SubcomandoQué hace
add-member --pubkey <npub|hex> [--role member|admin]Agrega la fila a relay_members y publica el roster kind:13534
remove-member --pubkey <npub|hex> [--role <guard>]Quita la fila y republica el roster
list-membersLista pubkey, role, added_by y created_at de la comunidad resuelta
generate-keyGenera un par de claves Nostr nuevo e indica fijar BUZZ_PRIVATE_KEY
migrateCorre las migraciones pendientes de base de datos
product-feedback list [--limit N]Lista feedback de producto como JSON; --limit va de 1 a 1000, default 100
reconcile-channels [--relay-key <hex>]Emite eventos 39000/39001/39002 para canales que no los tienen. Idempotente

Agregar y quitar

El argumento acepta npub bech32 o hex de 64 caracteres indistintamente: el CLI llama a nostr::PublicKey::parse y normaliza a hex minúscula. El rol por defecto es member.

docker compose exec relay buzz-admin add-member --pubkey npub1abc...
docker compose exec relay buzz-admin add-member --pubkey <64-char-hex-pubkey> --role admin
docker compose exec relay buzz-admin remove-member --pubkey npub1abc...
docker compose exec relay buzz-admin remove-member --pubkey npub1abc... --role member

add-member es idempotente hacia el usuario: si la pubkey ya está en la lista, imprime already a member: <hex> (no change) y devuelve 0.

El rol owner está prohibido por CLI. validate_role acepta exactamente "member" y "admin"; con "owner" devuelve error: role 'owner' cannot be set via CLI — use RELAY_OWNER_PUBKEY config.

El flag --role en remove-member no es el rol a asignar: es una guarda. Solo quita al miembro si su rol actual coincide con el valor dado. Es la forma segura de escribir automatizaciones que no deben degradar accidentalmente a un admin.

Quitar al dueño no es posible por esta vía: el CLI responde error: cannot remove relay owner: <hex> e indica actualizar RELAY_OWNER_PUBKEY y reiniciar.

Listar

docker compose exec relay buzz-admin list-members imprime una tabla de ancho fijo con columnas pubkey, role, added_by y created_at en formato %Y-%m-%dT%H:%M:%SZ. Con la lista vacía imprime (no relay members) y sale con 0.

Códigos de salida

CódigoSignificado
0Éxito
1Error de validación: pubkey mala, rol malo, error de uso
2No encontrado — remove-member sobre un miembro inexistente
3No se puede quitar al dueño del relay
4El rol no coincide: falló el chequeo de --role
5Error de DB, Redis o interno

Hay un matiz que rompe la intuición: si la escritura en base de datos funciona pero la publicación del roster falla, el CLI imprime warning: member added to DB but list publish failed: <error> y devuelve 0 igualmente. La membresía quedó aplicada; lo que no llegó es la notificación a los clientes vivos.

Variables de entorno que exige el CLI

VariablePara qué
DATABASE_URLConexión Postgres. Sin ella cae al default de desarrollo
REDIS_URLConexión Redis para publicar el roster. Default redis://localhost:6379
BUZZ_RELAY_PRIVATE_KEYObligatoria para add-member y remove-member
RELAY_URLDetermina qué comunidad se administra

Si falta la clave privada del relay, connect_member_services() aborta: “BUZZ_RELAY_PRIVATE_KEY is required for add-member/remove-member. The relay must have a stable signing key to publish kind:13534 events.” Sin una clave estable, los clientes no podrían verificar el roster entre reinicios.

Qué comunidad administra el CLI

buzz-admin corre dentro del contenedor del relay, así que comparte su RELAY_URL. De ahí deriva la autoridad con el mismo helper que usa el seeding de arranque y la resolución en vivo — buzz_core::tenant::relay_url_authority, que conserva host más puerto no-default explícito y los corchetes de IPv6 — y busca esa autoridad en el mapa durable communities. No hay tenant por defecto: un host no mapeado falla cerrado con RELAY_URL host '<host>' is not mapped to a community. El CLI es mono-comunidad por invocación.

Los atajos de run.sh

En el bundle de Docker Compose del capítulo 5, deploy/compose/run.sh envuelve tres de estos comandos con argumento posicional:

cd deploy/compose
./run.sh add-member npub1abc... --role admin
./run.sh remove-member npub1abc... --role member
./run.sh list-members

# Alta masiva: serial y con sleep, nunca xargs -P
while read -r npub; do
  ./run.sh add-member "$npub"
  sleep 1                       # sin esto, colisiones en el roster kind:13534
done < miembros.txt

Un detalle operativo del wrapper: los subcomandos de miembros invocan docker compose exec relay /usr/local/bin/buzz-admin ... sin los -f ni el --env-file que sí usa el resto del script, y require_env — la función que aborta si quedan valores CHANGE_ME en .envno corre antes de ellos.

El roster kind:13534 y por qué no hay deltas

Tras cada alta o baja, el relay o el CLI publica un evento kind:13534 — la lista de membresía NIP-43 — firmada con BUZZ_RELAY_PRIVATE_KEY. Su contenido va vacío: toda la información viaja en los tags, que son un ["-"] primero (el marcador de evento protegido NIP-70, que impide que terceros re-difundan el roster a otros relays) seguido de un ["member", "<pubkey-hex>", "<role>"] por cada miembro. Los clientes se suscriben con nak req -k 13534 --auth --sec <privkey> ws://localhost:3000.

Dos limitaciones declaradas en el código

El CLI intencionalmente no emite deltas kind:8000/kind:8001. El registro de kinds define KIND_NIP43_MEMBER_ADDED (8000) y KIND_NIP43_MEMBER_REMOVED (8001), pero el helper publish_nip43_delta es in-process únicamente: no da el salto por Redis, así que una llamada desde un sidecar almacena pero nunca empuja. El snapshot 13534 es el roster autoritativo y es el que viaja por Redis a los clientes vivos.

El guard de timestamp no serializa procesos concurrentes. Al publicar, el CLI calcula custom_created_at = max(now, newest_existing_13534 + 1s). Eso derrota la dominación en el mismo segundo para invocaciones seriales — un evento reemplazable con timestamp anterior o igual sería descartado — pero no serializa procesos CLI concurrentes: dos adds casi simultáneos pueden leer el mismo timestamp más nuevo y colisionar en el segundo incrementado. La serialización de run.sh es la única protección real contra adds paralelos.

Camino B: comandos admin sobre WebSocket

La membresía también se gestiona con eventos firmados por un usuario, sin acceso al contenedor. Requieren que el remitente esté autenticado por NIP-42 como dueño o admin del relay. La matriz de permisos está declarada en la cabecera de crates/buzz-relay/src/handlers/relay_admin.rs:

KindAcciónTagsRol requerido del remitente
9030Agregar miembro["p", "<hex-pubkey>"], opcional ["role", "member|admin"]admin o owner
9031Quitar miembro["p", "<hex-pubkey>"], opcional ["role", "member|admin"]admin o owner
9032Cambiar rol["p", "<hex-pubkey>"], ["role", "member|admin"]owner únicamente
9033Fijar icono de workspace["icon", "<https-url o data:image/* URL>"], vacío limpiaadmin o owner; en un relay abierto cuya comunidad no tiene ninguna fila admin ni owner, cualquier remitente autenticado

Nótese la asimetría deliberada: un admin puede agregar y quitar miembros, pero no puede promover a nadie a admin — cambiar roles es privilegio exclusivo del dueño.

# Agregar un miembro: firma el owner o un admin
nak event -k 9030 --tag "p=<target-hex-pubkey>" --tag "role=member" \
  --auth --sec <owner-or-admin-privkey> ws://localhost:3000

# Promover a admin: solo el owner
nak event -k 9032 --tag "p=<target-hex-pubkey>" --tag "role=admin" \
  --auth --sec <owner-privkey> ws://localhost:3000

Estos eventos se procesan directamente: mutan la tabla relay_members y retornan sin ser almacenados como eventos Nostr regulares, así que no los buscarás con un REQ. Y el extractor del tag p exige exactamente 64 caracteres hexadecimales: a diferencia del CLI, aquí no se acepta npub.

Quien firma qué, resumido:

flowchart LR
    admin["Owner o admin<br/>clave de usuario"] -->|"firma 9030, 9031, 9032, 9033"| relay["buzz-relay"]
    cli["buzz-admin<br/>dentro del contenedor"] -->|"escribe relay_members"| db[("Postgres")]
    relay --> db
    relay -->|"firma kind 13534"| redis["Redis pub/sub"]
    cli -->|"firma kind 13534"| redis
    redis --> clients["Clientes vivos"]

Roles: qué puede hacer cada uno

Plano de relay o comunidad

RolCómo se obtieneQué habilita
ownerSolo por RELAY_OWNER_PUBKEY en el arranqueTodo lo de admin, más cambiar roles (9032). No se puede quitar por CLI
adminbuzz-admin add-member --role admin, o 9032 firmado por el ownerAgregar y quitar miembros, fijar el icono de workspace, moderar la comunidad
memberRol por defecto de add-memberConectarse y participar según su membresía de canal

Plano de canal

El tipo member_role define cinco valores: owner, admin, member, guest, bot. La tabla channel_members los guarda por (community_id, channel_id, pubkey) y el borrado es suave: removed_at marca la salida y volver a agregar al usuario la revierte.

Los dos planos son independientes. Ser admin del relay no te hace owner de un canal privado en el que nunca entraste; lo que sí te da es una capacidad de moderación transversal, que veremos más abajo.

Canales: tipos, visibilidad y quién los crea

Tipos y visibilidad

Ambos son enums de Postgres, no strings libres:

CREATE TYPE channel_type AS ENUM ('stream', 'forum', 'dm', 'workflow');
CREATE TYPE channel_visibility AS ENUM ('open', 'private');
CREATE TYPE channel_add_policy AS ENUM ('anyone', 'owner_only', 'nobody');

stream es el canal de chat clásico con mensajes kind:9; forum son hilos de tipo foro (kinds 45001–45003); dm es una conversación directa, que emite kind:39000 con tag hidden; y workflow es un canal asociado a automatización.

La visibilidad por defecto es open. Un canal private es invisible para quien no es miembro: no aparece en listados y los filtros de suscripción para sus eventos no devuelven nada. Un matiz de protocolo que conviene entender antes de que un cliente de terceros te confunda: el evento de metadata kind:39000 siempre emite el tag closed, por convención NIP-29, porque los canales de Buzz requieren membresía explícita. Pero los canales open siguen siendo legibles y escribibles por no-miembros en tiempo de ejecución. El tag refleja el modelo de membresía, no la aplicación de acceso. Aparte, channels.community_id es inmutable: un trigger trg_channels_community_id_immutable lanza excepción si un UPDATE intenta cambiarlo.

Crear y editar

Un canal se crea con kind:9007, que acepta el tag name obligatorio más visibility y channel_type opcionales:

nak event -k 9007 --tag "name=my-channel" --tag "visibility=open" \
  --auth --sec <privkey> ws://localhost:3000

Las operaciones NIP-29 sobre canales, con sus reglas de autoridad reales:

KindOperaciónQuién puede
9000Agregar usuarioCanal open: cualquier usuario, sujeto al channel_add_policy del destinatario. Canal privado: owner o admin. El self-add evade la política de agente pero no la autorización de canal privado
9001Quitar usuarioAuto-removerse siempre, con guarda de último owner. Quitar a otros: owner o admin
9002Editar metadataTags name y about: owner o admin. Tags topic y purpose: cualquier miembro
9005Borrado administrativoEl autor siempre puede borrar lo suyo; en otro caso owner o admin. El objetivo debe estar en el mismo canal
9008Borrar el grupoSolo owner
9021Solicitar ingresoSolo canales open. Los privados se rechazan en ingest
9022Salir del grupoCualquier miembro, con guarda de último owner

El topic es la excepción del modelo: channels.topic, topic_set_by y topic_set_at los puede fijar cualquier miembro vía kind:9002, y existe además una columna topic_required BOOLEAN. Es deliberado: el topic es información operativa del canal, no gobierno.

Ojo con lo que no funciona: el kind:9009 (crear invitación) es aceptado y almacenado, pero su handler de efectos secundarios está diferido y hoy es un no-op con log de advertencia. Y el kind:39003 (roles de grupo) está definido en el registro de kinds pero el relay no lo emite.

Descubrimiento y notificaciones

El relay firma tres eventos de estado por canal:

KindContenido
39000Metadata del grupo. Siempre d, name y closed; about si hay descripción; private si aplica; hidden en canales DM
39001Lista de admins: tags p con etiqueta de rol owner o admin
39002Lista de miembros: tags p de todos los miembros

Se guardan channel-scoped, así que el control de acceso aplica: las listas de miembros de canales privados solo son visibles para miembros. La contrapartida es que una suscripción global viva {"kinds":[39000]} no las recibe por fan-out; los clientes descubren grupos con REQ históricos.

Si tienes canales creados por SQL directo o datos previos a una migración, no tendrán estos eventos: para eso existe buzz-admin reconcile-channels, idempotente, que salta los canales que ya tienen kind:39000. La clave de firma se resuelve por precedencia argumento, luego BUZZ_RELAY_PRIVATE_KEY, luego efímera; con clave efímera avisa que “los eventos firmados con esta clave no serán verificables después de esta ejecución”.

Los cambios de membresía de canal generan además notificaciones firmadas por el relay: kind:44100 (miembro agregado) y kind:44101 (miembro removido), con tags p de la pubkey objetivo y h del canal, almacenadas community-global para que agentes y clientes puedan suscribirse sin conocer los UUID de canal por adelantado. Dos reglas duras: los 44100/44101 enviados por clientes se rechazan (solo el par de claves del relay puede firmarlos), y los REQ globales que puedan coincidir con kinds p-gated (44100, 44101, 1059) deben incluir un filtro #p donde todos los valores sean la pubkey autenticada, o el relay responde restricted: p-gated events require #p matching your pubkey.

nak req -k 44100 -k 44101 --tag "p=<your-hex-pubkey>" \
  --auth --sec <privkey> ws://localhost:3000

Moderación

Borrado de mensajes

Hay dos caminos distintos y conviene no mezclarlos. kind:5 (NIP-09) es el borrado del propio autor: requiere el tag #e apuntando al evento objetivo, el #h es opcional, y el relay valida la coincidencia de autor contra el evento apuntado. Solo se pueden borrar eventos propios.

nak event -k 5 -c "razon" --tag "h=<channel-uuid>" --tag "e=<message-event-id>" \
  --auth --sec <privkey> ws://localhost:3000

kind:9005 (NIP-29) es el borrado administrativo. El autor siempre puede borrar lo suyo; para borrar de otros hace falta ser owner o admin, y el evento objetivo debe estar en el mismo canal. Esa es la distinción canónica de NOSTR.md: “solo se pueden borrar eventos auto-firmados; los borrados administrativos usan kind:9005”.

Reportes: NIP-56

Un miembro reporta con un kind:1984. El tipo debe pertenecer al vocabulario aceptado en ingest, definido en crates/buzz-relay/src/handlers/report.rs:

pub const REPORT_TYPES: &[&str] = &[
    "illegal", "nudity", "malware", "spam",
    "impersonation", "profanity", "other",
];

Lo importante es cómo se guarda: el reporte se valida y se persiste en la cola tenant-scoped moderation_reports, y el evento en sí no se almacena ni se difunde como evento regular. La identidad del reportante no puede filtrarse por un bug futuro de consulta porque nunca estuvo en el almacén público. Los objetivos se resuelven solo bajo la comunidad de la petición: un target e se busca en este tenant y si no está, se rechaza; un target x (blob) usa la referencia de media tenant-scoped (community_id, sha256), porque un SHA-256 pelado es compartido entre tenants y no debe conceder visibilidad cruzada.

Y la regla de diseño que define todo el sistema: los reportes son señales, nunca disparadores. El relay jamás actúa automáticamente sobre un reporte.

Comandos de moderación: kinds 9040–9044

La aplicación de decisiones son comandos firmados, con la misma forma que los admin NIP-43: se validan y ejecutan directamente, y nunca se almacenan como eventos.

KindOperaciónTags
9040Ban de la comunidad["p", "<hex>"] requerido; opcional ["expiration", "<unix>"] — ausente implica permanente — y ["reason", "<texto>"]
9041Levantar el ban["p", "<hex>"]
9042Timeout, bloqueo de escritura["p", "<hex>"] más ["expiration", "<unix>"] requerido; opcional ["reason", ...]
9043Limpiar el timeout antes de tiempo["p", "<hex>"]
9044Resolver un reporte["report", "<report-event-id-hex>"], ["status", "resolved|dismissed"], ["action", "delete|kick|ban|timeout|dismiss|escalate"]; opcional ["reason", ...]

Los efectos secundarios de cada uno son obligatorios, no opcionales: un ban escribe en community_bans, deja una fila de auditoría, desconecta las sesiones vivas de esa clave y envía un aviso de restricción; un timeout hace un upsert de muted_until, audita y notifica. Además son comandos community-global: están listados en is_global_only_kind, de modo que un tag h perdido nunca puede scopearlos a un canal, exigen timestamp fresco y rechazan tokens de API scopeados a canal.

La matriz de capacidades

crates/buzz-relay/src/handlers/moderation_authz.rs define un único punto de autorización, authorize_moderation_action:

CapacidadCommunity owner/adminChannel owner/adminMember
DeleteMessageToda la comunidadSolo en su canalNo
KickToda la comunidadSolo en su canalNo
Ban / UnbanNoNo
Timeout / UntimeoutNoNo
ResolveReportNoNo
ViewQueueNoNo

La invariante de tenant es explícita: la autoridad nunca cruza la valla de comunidad. El rol del actor se lee de relay_members y channel_members bajo tenant.community() únicamente.

El CLI buzz moderation

buzz-cli expone el grupo moderation. Las mutaciones son eventos firmados 9040–9044 enviados por POST /events; las lecturas van contra endpoints dedicados /moderation/* autenticados con NIP-98, porque reportes y filas de auditoría son filas estructuradas de cola, no eventos Nostr públicos.

buzz moderation reports --status open --limit 20
buzz moderation ban --pubkey <HEX> --expires-in 604800 --reason "repeated spam"
buzz moderation timeout --pubkey <HEX> --expires-in 3600
buzz moderation untimeout --pubkey <HEX>
buzz moderation resolve --report <REPORT_EVENT_ID> --status resolved --action ban --reason "rule 3"
buzz moderation restricted
buzz moderation audit --limit 50

timeout exige --expires-in o --expires-at. Y el cliente aplica una política de reintentos no idempotente a los kinds 9040–9044: ante una respuesta ambigua no reintenta a ciegas, informa moderation command (kind N) outcome unknown: .... Banear dos veces por un timeout de red no es aceptable.

Auditoría de la decisión

La fila de auditoría de una resolución registra la decisión, no la ejecución, y por eso se prefija: resolve:ban, resolve:delete, etc. La ejecución la escribe el comando 9040–9043 emparejado con su valor sin prefijo, así la traza nunca afirma que ocurrió algo que no ocurrió. Dos excepciones: dismiss audita como dismiss_report y escalate como escalate, sin prefijo, porque las escalaciones deben seguir siendo consultables por el carril de seguridad de plataforma. El registro de auditoría general se cubre en el capítulo 10.

VISION_MODERATION.md: dirección y bordes honestos

El documento VISION_MODERATION.md describe el bucle completo y contrapone ese enfoque al del resto del ecosistema Nostr: “la mayor parte del ecosistema trata la moderación como política de admisión; Buzz la trata como flujo de trabajo. Buena parte de ese bucle está en el código hoy y se puede verificar en crates/buzz-relay/src/handlers/. Pero el propio documento tiene una sección titulada “Honest Edges”, y esas piezas no están implementadas:

  • La escalación es un gancho, no una tubería. Escalar escribe un registro durable y consultable para el operador de plataforma, pero la bandeja de entrada del lado de plataforma que lo consume es una construcción aparte que no existe. El sustrato está; el tooling encima viene después.
  • Dos roles, no tres. Owners y admins moderan. No hay un nivel de moderador voluntario, y es deliberado: la autoridad está estructurada como capacidades, así que agregarlo más adelante es un cambio de política. Lo dice el comentario de moderation_authz.rs: “There is no Moderator tier in v1”.
  • Los avisos son best-effort. Los DM que cierran el bucle nunca bloquean la aplicación: un ban aterriza aunque el aviso falle. La aplicación es la promesa; la notificación es la cortesía.
  • No hay automod. Nada escanea contenido antes de publicarse. El filtrado previo al envío, la ponderación de reportantes de confianza y las listas de bloqueo compartidas son capas futuras.

Y un recordatorio del carril formal, del capítulo 11: la valla de admisión gobierna la capacidad actual. Revocar a un miembro quita su fila y por tanto su capacidad, pero no reetiqueta ni borra las filas que escribió mientras estuvo admitido“no afirmamos que las escrituras históricas queden revocadas cuando un miembro es revocado”.

Un dashboard de solo lectura, no una consola de moderación

Existe además un dashboard privado a nivel de despliegue, servido por el propio proceso del relay y activado con BUZZ_ADMIN_HOST y BUZZ_ADMIN_WEB_DIR. Muestra reportes abiertos y feedback reciente de producto. En local se levanta con just admin, y just admin-seed lo puebla con datos deterministas.

Es solo lectura: únicamente se enrutan GET y HEAD, sobre GET /api/admin/v1/reports, /reports/:id, /feedback, /feedback/:id y /feedback/:id/attachments/:sha256, con límites topados en 200. Su frontera de confianza humana es el ingress privado, y la documentación es honesta sobre lo que eso significa: “la admisión por VPN o IP de origen no es identidad por-operador. Cualquiera admitido al dashboard puede leer adjuntos de los registros de feedback a los que tenga acceso.” Traducido a operación: el dashboard es una ventana, no un panel de control.

Resumen

  • Hay dos planos de membresía: relay_members (NIP-43, roles owner/admin/member) y channel_members (NIP-29, roles owner/admin/member/guest/bot). La membresía de canal es el único mecanismo de control de acceso a contenido.
  • BUZZ_REQUIRE_RELAY_MEMBERSHIP=true activa la compuerta; RELAY_OWNER_PUBKEY bootstrapea al dueño en el arranque y es la única forma de fijar el rol owner.
  • buzz-admin expone siete subcomandos: add-member, remove-member, list-members, generate-key, migrate, product-feedback list y reconcile-channels. Acepta npub bech32 o hex de 64 caracteres, rol por defecto member, y --role en remove-member funciona como guarda. Códigos de salida: 0 éxito, 1 validación, 2 no encontrado, 3 es el dueño, 4 rol no coincide, 5 error interno; un fallo de publicación del roster tras escritura exitosa devuelve 0.
  • add-member y remove-member exigen BUZZ_RELAY_PRIVATE_KEY porque publican el roster kind:13534, firmado por el relay y protegido con NIP-70. El CLI no emite deltas 8000/8001 a propósito, y no serializa procesos concurrentes: sleep 1 entre invocaciones.
  • Por WebSocket: 9030 y 9031 los puede firmar un admin o el owner; 9032, el cambio de rol, es exclusivo del owner; 9033 fija el icono de workspace.
  • Los canales tienen cuatro tipos (stream, forum, dm, workflow) y dos visibilidades (open, private). El topic lo puede fijar cualquier miembro; el nombre y la descripción, solo owner o admin. kind:9009 es un no-op diferido y kind:39003 no se emite.
  • Moderación: kind:5 borra solo lo auto-firmado; kind:9005 es el borrado administrativo. Los reportes kind:1984 van a una cola privada y nunca disparan acciones. Los comandos 9040–9044 aplican bans, timeouts y resoluciones, autorizados por un único punto que nunca cruza la valla de comunidad.
  • De VISION_MODERATION.md, no implementado: la bandeja de plataforma que consume escalaciones, el nivel de moderador voluntario, y cualquier forma de automod o filtrado previo al envío. Los avisos son best-effort por diseño.

Siguiente: Medios, almacenamiento de objetos y git sobre object storage