Capítulo "empezar" de la guía Neblla — para qué existe Neblla, cuándo usarla y cuándo no. Léelo antes de decidir la arquitectura de una app. El contrato técnico está en core.txt. # Neblla — cuándo usarla **La IA hace tu app en minutos. Neblla la mantiene viva durante años.** El backend y el backoffice de tu juego, tu app o tu tienda: usuarios, datos, pagos, salas multijugador en tiempo real, comunidad, IA y un panel para gestionarlo todo. ## El reparto de trabajo Tú ya sabes escribir la app: el HTML, la interfaz, las reglas, y el canvas o el three.js si es un juego. Eso no es lo que aporta Neblla y no deberías esperar ayuda ahí. Lo que no puedes escribir en una respuesta, por bueno que seas, es lo que **sobrevive a que se cierre la pestaña** y lo que vive **entre los dispositivos**: - cuentas de usuario que son las mismas en todas las apps Neblla, con sus datos guardados y sus amigos; - dinero: cobrar de verdad, sin que el navegador del usuario pueda mentir; - contenido que publican tus usuarios, con su moderación; - borrar los datos de un usuario cuando lo pide (RGPD), con un botón; - un backoffice donde el dev y su equipo lo gestionan todo; - y, cuando hay más de un dispositivo, el multijugador en tiempo real: un estado compartido con una autoridad clara, que no se descuadra cuando dos personas actúan a la vez; salas que nacen, esperan gente, arrancan y mueren, con un código corto para entrar; y qué pasa cuando el dispositivo que llevaba la sala se cierra (sucesión) o cuando alguien vuelve a mitad (reconexión). Neblla es esa parte, ya construida y corriendo: **tú escribes la app; Neblla pone todo lo que va detrás y la mantiene viva.** La alternativa realista, si no la usas, es levantar un servidor de sockets, autenticación, una base de datos y un despliegue, y mantenerlos. Eso es lo que Neblla sustituye — no el hecho de escribir código. ## Neblla a la carta Neblla trae de fábrica todo lo que la app necesita, para que funcione desde el primer minuto. Amplíalo pasando a Pro, o reemplaza las partes que te vayan haciendo falta, una a una, gratis y sin tocar tu integración. **Neblla está hecha para que la app le cueste lo mínimo al desarrollador.** Todas sus features funcionan gratis desde el primer minuto, sin configurar nada —salvo las que lo piden por naturaleza, como cobrar con Stripe—, así que el dev las prueba de verdad antes de pensar en pagar. Solo cuando la app crece decide qué optimizar: pasar a Pro, o traer su propia base de datos, su almacenamiento o su servidor. **Neblla cobra por uso, nunca por feature ni por usuario:** no hay planes que esconden lo importante en el escalón de arriba. Y si para alguna pieza encuentras una oferta más barata, la traes y la integras con Neblla. Neblla es el backend y el backoffice de tu app: alojamiento y URL pública, base de datos con tus propias tablas, cuentas (las de Neblla o las tuyas), salas en tiempo real, pagos y suscripciones, comunidad, reservas y bonos, IA, un backoffice con paneles a medida para tu equipo y más. Úsalo entero o solo la pieza que te falte. El SDK es una etiqueta ` ``` Cuando la página carga, `onNeblla` se ejecuta con el SDK ya conectado. El script tag NO necesita `type="module"`. Si el SDK no puede arrancar, `onNeblla` no se ejecuta y se avisa una sola vez: define `window.onNebllaError = (detail) => { ... }` o escucha el evento DOM `neblla:error` (mismo `detail`). `detail.code` es `unreachable` (no se pudo contactar con Neblla), `not_found` (esta página no es de ninguna app: su origen no está en los dominios de la app), `refused` (la app no deja entrar a nadie nuevo ahora: `reason` es `quota_exceeded`, `storage_full`, `migrating`, `rate_limited` si esta dirección abrió demasiadas páginas en un minuto o creó demasiados invitados nuevos, o `turnstile_failed` si la app pide Turnstile y la página no lo pasó), `auth_failed` (el servidor rechazó la conexión, con `status`), `timeout` (sin login a los 15 s; el SDK sigue reintentando y `onNeblla` aún puede llegar) o `sdk_error`. Úsalo para pintar un "sin conexión — reintentar" en vez de una pantalla en blanco. Lo que tu código lanza y nadie atrapa llega a tu registro (el panel: Construir › Errores y rechazos; tu IA: `neblla_get_logs`), con su fichero y su línea: un error o una promesa rechazada en la página (`client.js` y tus otros ficheros) y lo que lanza un manejador de `lobby.js`, `gathering.js` o `room.js` en el par servidor de la sala. Es una muestra: como mucho 10 errores distintos por minuto desde cada página, y el mismo otra vez solo suma a su cuenta. No cambia nada para tu app: el error sigue saliendo en la consola como siempre. Los errores de otros orígenes (el navegador solo dice «Script error.») y los de las extensiones del navegador no se mandan. ## Estructura canónica — 6 ficheros Si la app vive alojada en Neblla, el link público apunta a `index.html`. La plantilla canónica son seis archivos; solo `index.html` es obligatorio, el resto existen si la app los necesita. Front-end (corre en el navegador): - **index.html** — markup. Carga `main.css`, `client.js` y el SDK de Neblla. - **main.css** — estilos. - **client.js** — corre en cada dispositivo conectado. Tiene acceso al DOM. El código va dentro de `window.onNeblla = (neblla) => { ... }`. Server-side (lógica aislada, sin DOM, carpeta `server/`): - **server/lobby.js** — usuarios que aún no están en partida. Matchmaking: recoger "estoy listo", agrupar, reservar sala, anunciar emparejamiento. - **server/gathering.js** — sala creada esperando a empezar. Decide *cuándo* arranca la partida (los casos comunes se configuran sin código en el panel, Funciones › Salas). - **server/room.js** — la sala en marcha. Autoridad de la sala: valida acciones, mantiene estado, anuncia cambios. Contrato de la estructura: - `index.html` referencia `main.css` y `client.js` con `` y ` Escríbenos ``` El enlace abre el formulario (email, nombre y mensaje, en el idioma de la página) y el mensaje entra en Personas › Buzón como el de cualquier usuario, en un hilo por email marcado «sin cuenta», con su aviso en Inicio (`inbox_contact`) hasta que lo lees o contestas. Lo que contestas le llega **por email**, desde Neblla, con Reply-To a la dirección que elijas (`neblla_update_app({ inbox: { replyTo: 'hola@tuweb.com' } })`, o en Personas › Buzón; vacío, tu email): si te responde, te llega a ti. `neblla_list_communications` marca esos mensajes con `contact: true`, y `neblla_send_communication` a ese `userId` manda el email (`communication.emailed` dice si salió). Solo vale desde una página de tu app (sus dominios permitidos, `.neblla.app` o play.neblla.com) y con el módulo `inbox` encendido (si no, `inbox_off`); hay un límite por dirección IP y 200 mensajes al día por app. Alguien sin cuenta no es un usuario: no sale en Personas › Usuarios, ni en los recuentos, ni en las exportaciones, ni le llega «Escribir a todos». Y aunque luego se registre con ese email, su hilo sigue aparte: escribir un email en un formulario no prueba que sea suyo. --- Capítulo comunidad de la guía Neblla — contenido creado y mantenido por los usuarios (entries), búsqueda y moderación. El contrato base está en core.txt; el criterio de cuándo usar Neblla, en empezar.txt. # Comunidad ## Qué es Un espacio de contenido **creado y mantenido por los usuarios** de tu app, mediado por Neblla: partituras, niveles, problemas de ajedrez, recetas, listings… Lo que se publica se llama **entry**. Tú no montas base de datos, ni endpoints, ni panel de moderación: el SDK publica, lista, busca y modera; Neblla almacena, indexa y hace cumplir la autoría. Una entry tiene tres capas: 1. **Payload** — JSON libre que solo tu app entiende. Neblla no lo indexa ni lo interpreta. Lleva un `contentType` (etiqueta libre, p. ej. `"musicxml"` o `"puzzle/v1"`) para que sepas cómo leerlo. Neblla no aloja assets **dentro de una entry**: si quieres una imagen ahí, la codificas en base64 en el payload y pagas su tamaño. (**Para lo que suben los usuarios** —fotos, PDF, audio— conecta el almacenamiento de ficheros que prefieras, como Cloudinary, S3 o R2: el navegador sube el fichero directo allí y la entry guarda solo su URL: con Cloudinary, con un preset de subida; con R2 o S3, con una URL firmada que te da tu Server o una función pequeña tuya, porque se firma con tu clave. Crece sin límite y no ocupa tu almacenamiento ni tu cuota. Los ficheros de la propia app (300 por cuenta gratis, compartidos por sus apps; 600 por proyecto Pro) son para tu código y tus assets.) **Y cuándo eso está bien tiene una regla, porque en pagos.txt te decimos lo contrario y no es una contradicción:** un payload de entry se lee de uno en uno —alguien abre *esa* entry— así que pagas la imagen cuando alguien de verdad la mira. Un catálogo de tienda, en cambio, se pinta entero en cada visita, así que ahí el base64 te cobra todas las imágenes a todo el que entra y la URL externa es la respuesta. Regla corta: **base64 para lo que se abre de uno en uno, URL para lo que se lista.** 2. **Campos indexados** (fijos, de Neblla) — `title`, `summary`, `category`, `tags`, `lang`, `contentType`, `parentId`, `status`, `owner`, `stats`, fechas. Por ellos se lista, busca y ordena. 3. **Relación usuario↔entry** (favorito, valoración, reseña, denuncia, voto, dato privado) — ya está — ver "Favoritas, valoraciones, reseñas y denuncias" y "Votos" más abajo. Requiere el módulo `community` encendido en Config → Módulos (o `neblla_update_app` con `features: { community: true }`). Está **apagado por defecto**: encenderlo implica moderar. ## Modos de moderación (panel: Personas › Comunidad) - **open** — la entry se publica al instante. - **review** — nace `pending`; tú (backoffice) o un moderador de la app la aprueban o rechazan. Si el autor edita una entry publicada, vuelve a `pending`. - **closed** — solo publican los moderadores (tienda curada). Otros ajustes: quién publica (`users` | `moderators`), límite antiabuso de payload por entry (64 KB por defecto; lo ajustas o lo quitas), vocabulario de categorías y de `contentType` (vacío = libre), tope de entries por usuario, texto de reglas. **Las reglas las acepta cada usuario.** Si escribes reglas (`community.rules`), la primera vez que un usuario publica (o edita una entry listada), Neblla le enseña tus reglas en un pop-up pequeño suyo, traducidas a su idioma, y la entry sale cuando las acepta; tú no programas nada. Se guarda la versión que aceptó: si cambias el texto, en su siguiente entry se le vuelven a pedir. Sin reglas no sale nada, las entries privadas no las piden y tú (MCP, tu servidor) tampoco. ### Filtro previo con IA (opcional) `community.prescreen = { mode: 'off' | 'flag' | 'hold', images: false }`. Cuando un usuario publica o edita, la IA integrada lee el título, el resumen y el texto del payload (y, con `images: true`, hasta 3 imágenes en base64 del payload) contra tus reglas. Usa **tu** clave de Claude, la misma de `neblla.ai.prompt()`: cuesta tus tokens y por eso viene apagado. - **`flag`** — la entry se publica igual; si la IA pone pegas, lleva `flagged: { reason }`, visible para todos, para que tu app la difumine o avise antes de enseñarla. Un moderador la limpia con `moderate(id, { action: 'approve' })`. - **`hold`** — si la IA pone pegas, la entry espera en la cola (`pending`) con `moderation.by: 'prescreen'` y el motivo. Si no se pudo comprobar (sin clave, o la IA no respondió), también espera, con `moderation.unavailable: true`: una entry sin revisar no pasa sola. - Una edición se vuelve a revisar entera; si arregla una entry retenida, sale publicada (salvo en modo `review`). Los moderadores y tú no pasáis por el filtro, ni las entries privadas. Estados de una entry: `published` · `pending` · `rejected` · `removed`. Solo las `published` aparecen en `list()`; el autor ve todas las suyas en `mine()` con el motivo de moderación si lo hay. ## Publicar y gestionar lo mío (CLIENT, requiere login) ```js const entry = await neblla.community.publish({ title: 'Where Is My Mind', summary: 'Arreglo fácil para piano', category: 'pop', tags: ['fácil', 'pixies'], lang: 'es', contentType: 'musicxml', payload: { xml: '…' }, }); // entry.status === 'published' (modo open) o 'pending' (modo review) await neblla.community.update(entry.id, { summary: 'Arreglo fácil, dos manos' }); await neblla.community.unpublish(entry.id); // el autor la retira: se borra con sus // reseñas, favoritos y entries hijas. Para corregir una entry, usa update(). En modo // `review`, una edición vuelve a `pending` hasta que un moderador la aprueba. const { entries, next } = await neblla.community.mine(); // todas mis entries, todos los estados ``` Errores (la promesa rechaza con `err.code`): `login_required`, `feature_disabled`, `title_required`, `contentType_required`, `payload_required`, `payload_too_large` (`err.bytes`, `err.max`), `category_not_allowed` / `content_type_not_allowed` (`err.allowed`), `too_many_tags`, `entry_limit_reached`, `moderators_only`, `community_muted`, `community_suspended` (Neblla puso la comunidad en pausa), `community_storage_exceeded` (`err.current`, `err.cap`), `not_owner`, `entry_not_found`, `parent_not_found`, `rules_declined` (cerró el pop-up de las reglas sin aceptarlas), `popup_blocked` (el navegador bloqueó ese pop-up; `err.url` lo abre desde un botón). Evento `neblla.community.on('reportClosed')` `{ entryId, outcome }` (CLIENT): se cerró el caso de una entry que denuncié. `outcome` es `upheld` (se retiró) o `dismissed` (sigue publicada). No trae el mensaje del moderador: ese es para el autor. Evento `neblla.community.on('updated')` `{ entry, action }` (CLIENT): moderación tocó una entry mía. `action` ∈ `published` | `pending` | `rejected` | `removed` | `restored` | `modified` | `suspended`. `entry.moderation` trae `{ reason, message }` escrito por el moderador. ## Hilos, colecciones y versiones `parentId` cuelga una entry de otra. Con un solo mecanismo salen los comentarios (una entry hija con payload de texto), las colecciones y las versiones o remezclas con procedencia. `list({ parent: id })` devuelve las hijas; `list({ parent: null })` solo raíces. La madre suma `stats.children`. El `parentId` se fija al publicar y no cambia. ## Listar, buscar y leer (BOTH — también para invitados) ```js const { entries, next } = await neblla.community.list({ q: 'pixies', // texto libre sobre title/summary/tags category: 'pop', tags: ['fácil'], // todas las etiquetas dadas lang: 'es', contentType: 'musicxml', owner: 'me' | '', parent: null | '', sort: 'recent' | 'oldest' | 'title' | 'popular' | 'top' | 'used' | 'votes', // con q, por relevancia si no indicas sort cursor: next, // paginación opaca; limit ≤ 50 (default 20) limit: 20, }); const facets = await neblla.community.facets(['category', 'tags']); // { category: [{ value, count }], tags: [...] } const { entry, payload } = await neblla.community.get(entryId); // el payload solo aquí ``` `list()` devuelve **solo la capa indexada** más `stats`; el payload se pide entry a entry con `get()`. Cada entry incluye `owner: { id, name, slug }` (nunca el email) y `mine: true` si es tuya. Los tres campos no son de la misma naturaleza y conviene saberlo: **`id` es el userId por app** —el mismo de `neblla_list_users`, así que puedes ir de un usuario a todo lo que publicó sin cruzar nombres— mientras que **`name` y `slug` son la identidad pública de la persona y son las mismas en cualquier otra app Neblla**. Si te importa la privacidad de tus usuarios, ese es el campo que viaja entre apps, no el id (ver identidad.txt). ### Campos propios Además de `category` y `tags`, cada app declara sus campos, con tipo, y filtra por rangos: ```js // una vez: neblla_update_app({ community: { fields: { difficulty: 'integer', bpm: 'number', genre: 'string' }, index: ['difficulty'] } }) await neblla.community.publish({ title, contentType, payload, fields: { difficulty: 3, bpm: 92, genre: 'jazz' } }); await neblla.community.update(id, { fields: { difficulty: 4 } }); const { entries } = await neblla.community.list({ where: { difficulty: { gte: 3, lt: 5 } }, orderBy: '-difficulty' }); ``` - Los campos vuelven en `entry.fields`, en `list`, `get` y `publish`. - `where` es `{ campo: valor }` o `{ campo: { eq, ne, gt, gte, lt, lte, in } }`, como en las tablas; `orderBy`, `'campo'` o `'-campo'`. Los de `index` van con índice. - Errores: `unknown_field` (no está declarado) e `invalid_field_value` (no es de su tipo). - Con BYODB, en tu base de datos. ### Una entry en varios idiomas `title`, `summary` y `lang` son el texto propio de la entry. Para que se lea en otros idiomas, publícala (o actualízala) con `i18n`: ```js await neblla.community.publish({ title: 'Ajedrez', summary: 'Juega contra tus amigos', lang: 'es', i18n: { en: { title: 'Chess', summary: 'Play against your friends' }, fr: { title: 'Échecs' } }, contentType: 'app', payload: { … }, }); const { entries } = await neblla.community.list({ q: 'echecs', locale: 'fr' }); const { entry } = await neblla.community.get(id, { locale: 'pt-BR' }); ``` - La búsqueda (`q`) encuentra la entry por las palabras de **cualquiera** de sus idiomas, y el filtro `lang: 'fr'` la encuentra si está en francés **o** traducida al francés. - Con `locale`, `list` y `get` devuelven `title`/`summary` en ese idioma si la entry lo tiene (primero el exacto, `pt-br`; luego el base, `pt`), y si no, el texto propio. Cada entry dice en `shownLang` en qué idioma está lo que ves y en `langs` todos los que tiene. Una traducción sin `summary` muestra el propio. - `get` trae además `text` (el texto propio) e `i18n` entero, para editarlo. En `update`, `i18n` **sustituye** el mapa completo; `i18n: null` lo vacía. - Hasta **16 traducciones**, con los mismos topes que el texto propio (`title` 120, `summary` 500). Errores: `invalid_i18n`, `invalid_i18n_lang` (código no válido: `fr`, `pt-br`, `zh-hant`), `too_many_translations` (`err.max`). ### Imagen de la tarjeta `image` es una URL `https://` (hasta 512 caracteres) que viaja con la entry en `list`, para pintar tarjetas sin pedir el payload de cada una. Opcional; `invalid_image` si no es una URL https. Neblla no la descarga ni la aloja: apunta a donde ya esté la imagen. ### Lectura pública por HTTP Lo publicado se puede leer sin arrancar el SDK, para la primera pintura de una página, un render en servidor o un buscador. Sin login y con CORS abierto: ``` GET /api/community/public//entries?q=&category=&tags=a,b&lang=&contentType=&parent=null&sort=&cursor=&limit=&locale= GET /api/community/public//entries/?locale= → { entry, payload } GET /api/community/public//entries//reviews?cursor=&limit= GET /api/community/public//facets?fields=category,tags ``` Mismas respuestas que `list`, `get`, `reviews` y `facets` para un invitado: solo entries publicadas y ninguna relación de nadie (no hay lector). Cuesta la misma cuota que la lectura por el SDK, pero las respuestas salen unos segundos de una caché: una página muy visitada que pide la misma lista paga una lectura por intervalo, no una por visitante. Errores: `404 feature_disabled` (la app no tiene comunidad), `404 entry_not_found`, `403 community_suspended`, `429` sin crédito. Para escribir, el SDK o tu servidor (`/api/backend/apps//community/{publish,update,delete}`, ver mcp.txt). Desde los ficheros de `server/` (`neblla` global) solo existen `community.list`, `community.facets` y `community.get`: Fog es el navegador de otro usuario y **nunca** publica ni modera en nombre de nadie. ## Favoritas, valoraciones, reseñas y denuncias (CLIENT, requiere login) Cada usuario tiene **una relación** con cada entry publicada. `visibility` tiene tres valores: - **`'public'`** (por defecto): la ve cualquiera, también los invitados y la lectura pública por HTTP. Sin cifrado. - **`'protected'`**: la ve un **grupo**. Los dueños de la entry son sus administradores, los editores que nombren pueden leerla y escribirla, y los moderadores de la app (el dev lo es siempre) pueden leerla, por ejemplo para aprobarla antes de hacerla pública. Va cifrada con una clave del grupo y no aparece en listados ni búsquedas de quien no esté en él: `publish({ …, visibility: 'protected', group: { editors: [userIdA, userIdB] } })`, y el grupo se cambia con `update(id, { group })`. - **`'private'`**: solo su autor. Su contenido (el payload) va **cifrado de extremo a extremo**: se cifra y se descifra en el navegador, y ni Neblla puede leerlo. El título, el resumen y las etiquetas van en claro, para que el autor pueda listar las suyas: no pongas ahí nada privado. Para abrirla en otro dispositivo tuyo o dársela a otra persona, se usa un enlace con clave (ver "Compartir en privado por enlace"). Cambiar de visibilidad conserva el grupo y sus roles, así que una entry puede pasar de protegida a pública y volver sin reconfigurar a nadie. Si moderación la retira, una entry **bloqueada** la siguen viendo sus dueños y los moderadores, y una **baneada** solo el dev y los moderadores; si era privada, tampoco ellos pueden leer su contenido, y solo su dueño puede quitarle el cifrado personal y pasarla a protegida. **Ojo a los nombres de campo, cambian entre escribir y leer.** El texto se escribe con `relate(id, { review: 'Preciosa' })`, pero `reviews()` devuelve cada una como `{ author, rating, text, at, mine }` — el texto llega en **`text`**, no en `review`. Pintar `r.review` te da una lista vacía y ningún error. **Topes de los campos indexados** (pasarse rechaza la publicación): `title` 120 caracteres, `summary` 500, `category` 40, **20 tags máximo** de 32 caracteres cada uno (`too_many_tags` lleva `max`), `lang` 8. `maxEntriesPerUser` vale **0 por defecto, que significa sin límite** (hasta 10.000); `reportThreshold` vale 3 por defecto (hasta 1000) y se configura en el `community` de la app. Escribirla es un merge parcial: tocar la favorita no borra la valoración. ```js await neblla.community.relate(entryId, { favorite: true }); const { relation, stats } = await neblla.community.relate(entryId, { rating: 4, review: 'Muy bonita', locale: 'es' }); // locale (opcional): el idioma en que está escrita la reseña; reviews() lo devuelve en `lang` // relation = { favorite, rating, review, reported, vote } · null borra una ranura: { rating: null } // stats = { favorites, ratingCount, ratingSum, ratingAvg, reviews, reports, children, uses, upvotes, downvotes, score } const mia = await neblla.community.relation(entryId); // mi relación con esa entry const { entries, next } = await neblla.community.favorites(); // mis favoritas publicadas const { reviews, next: n2 } = await neblla.community.reviews(entryId); // BOTH: reseñas públicas, recientes primero // reviews[i] = { author: { id, name, slug }, rating, text, at, lang?, mine, reply } reply = { text, at } | null await neblla.community.reply(entryId, reviews[0].author.id, '¡Gracias! Más niveles pronto'); // el autor de la entry responde (null la retira) await neblla.community.report(entryId, { reason: 'copyright', message: 'Es un arreglo con derechos.' }); // → { ok, suspended } reason ∈ copyright | inappropriate | hate | harassment | spam | illegal | privacy | other ('other' exige message) ``` - `list()`, `get()`, `mine()` y `favorites()` traen en cada entry `me: { favorite, rating, review, reported, vote }` si hay login: pintas "mis estrellas" sin una llamada por fila. `sort: 'popular'` ordena por favoritas, `'top'` por valoración media, `'used'` por usos y `'votes'` por votos. - **Responder reseñas.** El autor de la entry responde cada reseña con `reply(entryId, reviewerId, texto)`, donde `reviewerId` es el `author.id` de la reseña; también pueden los moderadores, y el dev desde su servidor (`POST /api/backend/apps//community/reply`) o el MCP. Una respuesta por reseña: la nueva sustituye a la anterior y `null` la retira. Sale bajo la reseña en `reviews()` como `reply` y, para quien reseñó, en su `relation()`. Si retira su reseña, la respuesta se va con ella. Errores: `not_author`, `review_not_found`, `reply_too_long` (1000 caracteres). - **Usos.** Cada entry lleva `stats.uses`, que solo suma su dueño desde su servidor: `POST /api/backend/apps//community/uses` con `{ entryId, n }` (por defecto 1). `sort: 'used'` lo lee: «más jugadas», «más descargadas», «más abiertas». - El autor no puede valorar, reseñar ni denunciar su propia entry (`own_entry`); sí marcarla como favorita. - Una denuncia por usuario y entry (`already_reported`), máximo 20 al día por cuenta (`report_limit_reached`). El autor nunca sabe quién denunció; el dev lo ve como id opaco. - Al llegar a `community.reportThreshold` denunciantes distintos (por defecto 3; 0 = nunca) la entry se auto-suspende: su `status` pasa a **`pending`** (no a `suspended`), desaparece del listado y el autor recibe `community.on('updated')` con `action: 'suspended'`. Eso importa al trabajar la cola: en `pending` conviven las entries que esperan primera aprobación y las que han caido por denuncias — distínguelas por `moderation.by`, que vale `'reports'` en las automáticas. El status `suspended` es el que pone un moderador a mano mientras hay un caso abierto. Aprobarla o restaurarla desestima las denuncias; retirarla las da por buenas. No todas las denuncias pesan igual para ese umbral: quien acumula denuncias desestimadas cuenta menos — la mitad si tiene más desestimadas que confirmadas, y nada si tiene 3 o más desestimadas y ninguna confirmada (su denuncia queda igualmente registrada para el moderador). Nadie cuenta más de uno. - Un usuario silenciado (`community_muted`) no puede relacionarse con entries, pero sí denunciar. ### Votos Cada usuario con login da **un voto por entry**: a favor y, si tu app lo enciende, también en contra. Sirve para todo lo que se elige entre muchos: en una app de vecinos, cada propuesta («poner bancos en la plaza») tiene sus votos y salen primero las más votadas; igual las ideas de un foro, las recetas o las sugerencias de un curso. ```js await neblla.community.vote(entryId); // a favor (lo mismo que vote(entryId, 1)) await neblla.community.vote(entryId, -1); // en contra: solo con community.downvotes: true const { vote, stats } = await neblla.community.vote(entryId, null); // quitarlo (0 también) // stats = { …, upvotes, downvotes, score } score = a favor − en contra const { entries } = await neblla.community.list({ sort: 'votes' }); // las más votadas primero // entries[i].stats.score, y entries[i].me.vote = 1 | -1 | null con login ``` - Votar otra vez cambia el voto, no suma otro. También vale `relate(entryId, { vote })`. - El voto en contra está **apagado por defecto**: actívalo con `neblla_update_app({ community: { downvotes: true } })` o en el panel (Personas › Comunidad, «Votos en contra»). Sin él, `vote(id, -1)` rechaza con `downvotes_off`. Apagarlo después hace que los votos en contra que ya hay dejen de contar: `downvotes` sale 0, `score` es solo los votos a favor y `sort: 'votes'` ordena por ellos. No se borran: si lo vuelves a encender, vuelven a contar. Quien votó en contra sigue viendo su `me.vote: -1` y puede retirarlo con `vote(id, 0)`. - `sort: 'votes'` ordena por `score`; a igual `score`, la que tiene más votos a favor. - El autor no vota lo suyo (`own_entry`). Un voto que no es `1`, `-1`, `0` ni `null` da `invalid_vote`. - **Nadie ve quién votó**: ni el autor, ni los demás, ni el dev. Solo los recuentos y, cada uno, su propio voto en `me.vote`. - **Los votos de un usuario baneado no cuentan.** Al banearlo (*banned* o *terminated*) sus votos salen de los recuentos, y vuelven si levantas el baneo. Uno silenciado (`community_muted`) no puede votar, pero lo que ya votó sigue contando. Si lo borras de tu app, sus votos se van con él. - Cuesta lo mismo que una favorita: `community_query` por voto. ## Compartir en privado por enlace (CLIENT, requiere login) Una entry **privada** no aparece en `list`, `facets` ni búsquedas, y va cifrada de extremo a extremo. Un **enlace con clave** la abre: el enlace lleva la clave de descifrado detrás de `#`, que el navegador nunca manda al servidor, así que quien tiene el enlace la lee y Neblla sigue sin poder. Sirve para mandar algo a una persona concreta (por WhatsApp, email…) sin publicarlo. ```js // 1. El autor crea la entry privada (no pasa por la cola de revisión) y un enlace const e = await neblla.community.publish({ title: 'Para Gonzalo', contentType: 'partitura', payload, visibility: 'private' }); const { token, key, expiresAt } = await neblla.community.shareLink(e.id, { uses: 1, // aperturas permitidas (1 = una sola descarga; máx. 100) expiresInDays: 10, // caducidad (máx. 30) deleteEntry: true, // al gastarse el último uso (o caducar) se borra también la entry }); const url = location.origin + location.pathname + '?envio=' + token + '#k=' + key; // la clave, detrás de '#' // 2. Quien lo recibe abre la app con ese enlace, entra con Neblla y: const { entry, payload } = await neblla.community.openLink(token, key); // gasta un uso; descifra en su navegador ``` - El token son 256 bits aleatorios (base64url, 43 caracteres); Neblla guarda solo su SHA-256, así que tu base de datos nunca contiene un enlace utilizable. - `openLink` exige sesión (`login_required`). Un enlace desconocido, gastado o caducado responde siempre `link_not_found`, sin pistas. - Solo el autor puede crear enlaces de su entry (`not_owner`). Funciona igual con entries públicas (`deleteEntry: false`). - Los enlaces viven en la tabla `community_links` de tu base de datos (BYO-DB), como el resto de la comunidad. La clave no se guarda en ninguna parte: si se pierde el enlace, se crea otro. - **Tus otros dispositivos.** El mismo mecanismo abre tus entries privadas en otro navegador tuyo: `const { url } = await neblla.community.deviceLink()` da un enlace (o un QR) con tu clave personal; ábrelo en el otro dispositivo, con tu misma cuenta, y desde entonces ese navegador lee todas tus entries privadas. ### Ver y revocar mis enlaces ```js const mis = await neblla.community.links(); // o links({ entryId }) para una entry // → [{ id, entryId, usesLeft, expiresAt, createdAt, deleteEntry }], los más nuevos primero await neblla.community.revokeLink(mis[0].id); // → { revoked: true } ``` - `id` es el hash del token: sirve para revocar, pero no abre nada. El token en sí no se puede recuperar. - Solo el autor ve y revoca sus enlaces (`not_owner`); un `id` desconocido → `link_not_found`. ### Tarjeta pública de una entry Para que un usuario pegue una entry en Discord, WhatsApp o X: ```js const url = neblla.community.cardUrl(entry.id); // https://play.neblla.com/c// // … y al cargar tu app: const id = neblla.community.linkedEntry(); // el id si se llegó por una tarjeta, si no null if (id) { const { entry, payload } = await neblla.community.get(id); /* ábrela */ } ``` - La vista previa del chat sale de las etiquetas Open Graph: título, resumen e `image` de la entry (sin `image`, la portada de la app) y el nombre de la app. No lleva el payload ni lo lee. - Quien hace clic llega a tu app con `?entry=`; `linkedEntry()` lo lee (y descarta lo que no tenga forma de id). - Solo tienen tarjeta las entries publicadas. Una privada, pendiente, retirada o inexistente da el mismo 404: el enlace no revela que exista. Para compartir una privada, `shareLink`. - Revocar el último enlace de una entry creada con `deleteEntry: true` la borra también, igual que al gastarse. - Los enlaces caducados o gastados se limpian solos (con su entry desechable) cada vez que la app crea o lista enlaces. ## Moderar desde la app (CLIENT, solo moderadores de la app) **Quién modera.** Los moderadores de la app son los miembros de su grupo reservado `moderators` (identidad.txt, «Grupos y permisos»), por su id de usuario en la app: los nombras y los quitas desde el panel (Personas › Moderación), con `neblla_set_moderator` o, con el módulo de grupos, desde la API de administración y tu servidor (`group: 'moderators'`). El grupo vive en los datos de la app (con BYODB, en tu base), así que sigue a los datos como los usuarios: pre con los datos de pro modera con los de pro; pre con sus propios datos tiene su grupo, vacío al empezar: añádete con tu usuario de pre (`neblla_set_moderator` con `env: 'pre'`, o el back office en pre) o añade a quien quieras, y moderas allí sin tocar pro. ```js const { entries } = await neblla.community.queue({ status: 'pending' }); await neblla.community.moderate(entryId, { action: 'approve' }); await neblla.community.moderate(entryId, { action: 'reject', reason: 'copyright', message: 'Partitura con copyright vigente.' }); // action ∈ approve | reject | remove | restore | suspend // reason ∈ copyright | inappropriate | hate | harassment | spam | illegal | privacy | other ``` `reject` y `remove` exigen `reason`; el autor recibe `community.on('updated')` con el motivo y el mensaje. `remove` conserva la entry y su payload (para apelar por la bandeja dev↔usuario); `restore` la devuelve a `published`. El backoffice del dev portal tiene la misma cola con contadores por estado, y el detalle de cada entry muestra sus denuncias agregadas por motivo. Sobre el autor, desde la ficha del usuario en el backoffice: **silenciar en la comunidad** (`community_muted`: sigue usando la app, no publica ni edita), *banned*, *terminated*. **La cola viene por gravedad.** Las entries `pending` y `suspended` llegan primero las más serias y, dentro de cada nivel, la más antigua primero; cada una trae `priority`: `notice` (aviso legal formal) > `serious` (ilegal, copyright, privacidad, odio o acoso) > `reports` (suspendida por denuncias) > `prescreen` (retenida o marcada por el filtro previo) > `review` (espera aprobación). `queue({ status: 'pending', sort: 'oldest' })` da el orden de llegada sin más. **Reincidencia.** `community.recidivism = { removals: 3, days: 30 }` (valores por defecto; `removals: 0` lo apaga): cuando a un mismo autor le retiran (`remove`) `removals` entries en `days` días, queda silenciado en la comunidad solo (`community_muted`, con `communityMutedBy: 'recidivism'`); `moderate` devuelve entonces `authorAction: 'muted'`. Si estando silenciado llega al doble, su ficha del backoffice y `neblla_list_users` muestran `banSuggested: true` (`authorAction: 'ban_suggested'`). Neblla nunca banea ni cierra una cuenta por su cuenta: esa decisión es tuya, y en cuanto cambias su estado la sugerencia desaparece. ## Notificaciones formales desde fuera de la app Cualquiera —sin cuenta y sin ser usuario de la app— puede presentar una notificación formal sobre una entry publicada: un titular de derechos o quien detecte contenido ilegal. El formulario está en `/notice?app=&entry=` y pide lo que exige un procedimiento de retirada: identidad y contacto del reclamante, la obra afectada, una declaración de buena fe y una firma. Presentarla suspende la entry al momento (`status: 'suspended'`, `moderation.by: 'notice'`, con `moderation.caseId`) y abre un caso. Te llega un email avisando de que hay un caso esperando; la reclamación, con los datos del reclamante, está en el detalle de la entry en el panel (Personas › Comunidad, filtro **Suspendidas**), donde la das por buena o la desestimas, y por MCP como `neblla_community_cases`. Al autor se le avisa por `community.on('updated')` y puede contra-notificar desde tu app: ```js await neblla.community.counterNotice(entryId, 'La compuse yo en 2023; aquí está el proyecto original.'); // → { cases: [{ caseId, status: 'countered' }] } solo el autor; errores: case_not_found, not_the_author, missing_field ``` El reclamante recibe la respuesta por email. Una contra-notificación **no** restaura la entry por sí sola, y cerrar un caso tampoco la vuelve a publicar: restaurar es un acto aparte y deliberado, para que ni una respuesta ni un clic sin querer devuelvan algo. Para poner un enlace de "denunciar un problema legal" en tu propia interfaz, `neblla.community.noticeUrl(entryId)` da la dirección del formulario para esa entry; la tarjeta pública de la entry (`cardUrl`) también enlaza a él. Como las notificaciones formales van dirigidas a Neblla como plataforma, Neblla también puede actuar por su cuenta: suspender o restaurar una entry (queda `moderation.by: 'platform'` y encabeza la cola) o poner en pausa la comunidad entera de una app. Durante la pausa los usuarios no pueden publicar, leer ni relacionarse con entries (`community_suspended`) y la tarjeta pública no se ve; tú recibes un email con el motivo, el backoffice lo muestra (y `neblla_get_community_config` trae `platformHold`), y sigues pudiendo revisar y moderar. Tu app sigue funcionando. ## Cuota y almacenamiento Publicar y editar cuestan cuota por bytes (`community_publish`); leer un payload cuesta sus bytes (`community_read`); listar, facetas, `mine`, la cola y cada favorita, valoración o voto tienen coste fijo (`community_query`). La comunidad comparte la base de datos de la app con los datos de usuario (50 MB por cuenta en gratis, compartidos por sus apps; 100 MB por proyecto Pro): al llenarse, `publish`/`update` rechazan con `community_storage_exceeded`. Todo el contenido de la comunidad es tuyo y vive en la base de datos de tu app: con BYO-DB, en **tu** base de datos (Turso, Postgres, MySQL o Mongo); sin él, en el Turso por defecto de Neblla. Tablas: `community_entries` (metadatos indexados, estado, moderación y contadores), `community_entry_tags`, `community_relations` (favoritas, valoraciones, reseñas, denuncias, votos), `community_links` (enlaces de compartir, solo el hash) y `community_payloads` (el payload); en Mongo, las colecciones `neblla_community_*`. Neblla solo guarda en su base la configuración de la app, el contador de almacenamiento y la identidad de los usuarios. Las entries de un usuario se borran cuando se borra al usuario de tu app (lo pide él, o lo haces tú desde el backoffice o con el MCP). Si borra su cuenta de Neblla, no: su fila en tu app y lo que publicó se quedan, sin cuenta Neblla detrás. ## Un ranking con comunidad Una tabla de puntuaciones se monta con entries de comunidad y un campo propio para ordenar: ```javascript // una vez: neblla_update_app({ community: { fields: { puntos: 'integer' }, index: ['puntos'] } }) // al acabar una partida, en client.js await neblla.community.publish({ title: nombre, category: 'score', contentType: 'score', lang: 'es', fields: { puntos }, payload: { at: Date.now(), segundos } }); // la tabla, legible también por invitados const { entries } = await neblla.community.list({ contentType: 'score', orderBy: '-puntos', parent: null, limit: 20 }); ``` Así la puntuación la publica el propio jugador y nadie la comprueba: vale para un ranking entre amigos sin nada en juego. Para uno que no se pueda falsear, `neblla.scores` (ver identidad.txt): la partida empieza en Neblla y lo imposible no entra. Usa `mode: 'open'` en la configuración de comunidad, para que cada puntuación se publique al momento en vez de pasar por la cola de revisión. Y si el ranking tiene que vivir con el resto de tus datos, `neblla_byodb` pone los datos de comunidad en tu propia base de datos, donde puedes consultarlo también con tu propio SQL. ## Un espacio compartido más grande que una sala Un documento que edita un equipo —un tablero, un documento colaborativo, el inventario de una partida larga, una wiki interna— vive aquí y no en `room.data`, que es para el estado de la sala. Una entry admite **64 KB de payload por defecto**, un límite antiabuso que cambias a lo que necesites con `neblla_update_app({ community: { maxPayloadBytes: 262144 } })`, o quitas con `maxPayloadBytes: 0`. La base de datos (50 MB por cuenta gratis, compartidos por sus apps; 100 MB por proyecto Pro, o la tuya con BYODB) da para unas ochocientas entries de 64 KB en gratis, y por defecto no hay tope de entries por usuario (`maxEntriesPerUser: 0`). Requisitos: el módulo `community` (`features: { community: true }`) y login. Los invitados leen; escriben los usuarios con login. **Solo para el equipo.** Publícalas `visibility: 'protected'` con el equipo como grupo, y lo interno del equipo no lo ve nadie más de la app. **Una entry por miembro.** Escribir en una entry es cosa de su dueño, de los editores de su grupo (si es `protected`) o de un moderador de la app. Aun así, para el estado de un equipo, dale a cada miembro su propia entry con una `category` común, y el estado del equipo es la **unión** de todas, que cualquiera lee con `community.list({ category })`. Así no hay conflictos de escritura por construcción, nadie necesita poderes de moderación, y quién cambió qué sale gratis porque cada trozo lleva su `owner`. **La sala avisa, comunidad guarda.** Para que los demás vean un cambio al momento, quien escribe hace su `community.update(...)` y manda un `message.send` a la sala con el id de la entry; los demás releen solo esa entry con `community.get(id)`. Por la sala solo viajan punteros de decenas de bytes, así que el tamaño del documento no depende del de la sala. **Lo que cuesta.** Publicar o actualizar cuesta los bytes del payload; leer una entry, la petición como un mensaje (64 + sus bytes) y la entry como respuesta (64 + sus bytes); un `list` o un `facets`, **50 unidades** fijas más la lista; y el aviso por la sala, su tamaño. Parte el estado del equipo en entries por miembro o por sección y relee solo la que cambió. BBDD propia con `neblla_byodb` mueve la base de datos a tu proveedor; el tráfico de comunidad se cuenta igual. --- Capítulo pagos de la guía Neblla — productos, checkout Stripe, validación y consumo. El contrato base está en core.txt; el criterio de cuándo usar Neblla, en empezar.txt. # Compras Integración con Stripe. El desarrollador no procesa pagos, no monta endpoints de Stripe, no gestiona los webhooks de Stripe. Si quiere enterarse de cada compra en su propio sistema (un n8n, un CRM), el webhook saliente `purchase` de Neblla se lo manda (ver core.txt). Tres tipos de producto: `one_time`, `consumable` y `subscription` (mensual o anual, con prueba gratis si quieres). Los tres son **entitlements dentro de la app** — un permiso o un contador atado a un usuario — y un producto único o un consumible puede además ser un **fichero a la venta** (una foto en alta resolución, un PDF, un pack de texturas: "Ficheros a la venta" más abajo), tener **unidades a la venta** (una edición de 10, "Unidades a la venta") o ser **físico**, enviado con sus gastos de envío y seguido como pedido ("Productos físicos y pedidos"). En el **pre** de la app las compras van, por defecto, por el modo de prueba de Stripe: nada se cobra y pro no las cuenta (ver publicar.txt, «Las compras en pre»). ## API `neblla.shop.purchases` es un **objeto indexado por id de producto**, no un array — `{}` si el usuario no tiene nada. Comprueba la propiedad con `neblla.shop.purchases['pack_invierno']`, nunca con `.some(...)`. ```javascript neblla.shop.products(); // listar lo que tienes en venta neblla.shop.buy('skin_dorado'); // abre checkout de Stripe neblla.shop.buy('monedas_pack', 100); // consumible con cantidad neblla.shop.consume('monedas_pack', 10); // consumir unidades neblla.shop.buy('premium'); // suscripción: se renueva sola neblla.shop.manage('premium'); // cancelar, cambiar la tarjeta, ver facturas neblla.shop.validate(userId, 'vip_pass'); // verificar (también desde el servidor) ``` Firmas: - `neblla.shop.buy(productId, amount?, { country }?)` **(CLIENT; un invitado compra con la cuenta ligera, ver identidad.txt, "Cuenta ligera")** — abre el checkout de Stripe en una ventana. Llámalo dentro del gesto del usuario (un click) para que el navegador no bloquee la ventana. La promesa se resuelve cuando la ventana está abierta, NO cuando se ha pagado: lo comprado llega después con `neblla.shop.on('purchase')`. Si apagaste la cuenta ligera, a un invitado le pide hacer login en esa misma ventana y sigue con el pago en cuanto entra; si cierra la ventana sin entrar, rechaza con `login_required`. Rechaza con el código del servidor (`feature_disabled`, …). Si el navegador bloquea la ventana, rechaza con `popup_blocked` y trae la `url` del checkout: pinta un botón que la abra — ese clic es un gesto nuevo y el navegador sí lo deja. Los datos de tarjeta nunca pasan por el código de la app. Los productos no se pueden comprar hasta que la cuenta de cobros del dev (Stripe Connect) está activa. - `neblla.shop.consume(productId, amount)` **(CLIENT)** — consume unidades. - `neblla.shop.validate(userId, productId)` **(BOTH)** — verifica posesión. También se puede llamar desde `room.js`, útil para gatear acciones autoritarias detrás de tener un producto: por ejemplo, para entrar en la sala VIP, `room.js` comprueba que el usuario tiene `vip_pass` antes de dejarle pasar, sin que pueda falsificarlo desde su cliente. - `neblla.shop.products()` **(BOTH)** — lista productos. Uno que espera a la cuenta de cobros sale con `waiting: true`: píntalo como «próximamente», sin botón de compra; se pone a la venta solo en cuanto la cuenta está activa. - **Cuando una compra no sale.** Todo error de `shop.buy` y `shop.manage` trae un `code` para decidir qué decirle al usuario (un juego, una app o una web, el mismo `code`): `product_waiting` (aún no se vende: «pronto»), `product_not_published`, `product_not_found`, `invalid_amount` (con `min`, `max` o `step`), `payouts_not_ready` y `stripe_test_account_required` (el dev aún no puede cobrar), `not_connected`/`login_required` (que entre), `feature_disabled`, `popup_blocked` (con la `url`), `checkout_failed` (cualquier otro); en `manage`, `not_subscribed` y `portal_failed`. Con `test: true` el fallo es del modo de prueba. Ejemplo: `try { await neblla.shop.buy('bono_10') } catch (e) { aviso(e.code === 'product_waiting' ? 'Muy pronto a la venta' : e.code === 'popup_blocked' ? botonQueAbre(e.url) : 'No se ha podido abrir el pago') }`. - `neblla.shop.testMode` **(CLIENT)** — `null` cuando se cobra de verdad; `{ card }` cuando la app vende en el modo de prueba de Stripe (el pre, o una app de ejemplo, de slug acabado en `-example`): nada se cobra y `card` es la tarjeta de prueba que paga. Díselo al visitante junto al botón de compra. - `neblla.shop.manage(productId)` **(CLIENT, login)** — abre el portal de Stripe del suscriptor, donde cancela, cambia la tarjeta o descarga sus facturas. Mismas reglas de ventana que `shop.buy`. - `neblla.shop.orders()` **(CLIENT, login)** — los pedidos de productos físicos del propio usuario, los más nuevos primero: `[{ id, productId, productName, units, status, zone, shipping, carrier?, tracking?, trackingUrl?, history, withdrawal, refunded? }]` ("Productos físicos y pedidos" más abajo). - **Pasar lo comprado a otro usuario:** si pones un producto de un solo pago o consumible en `neblla_update_app({ trade: { products: [...] } })`, sus unidades se pueden intercambiar con `neblla.db.offer` (ver identidad.txt, «Intercambios entre usuarios»). El pago se queda con quien pagó: lo que recibe el otro no es una venta (no sale en Ventas ni se reembolsa), y los dos oyen `purchase` con `type: 'trade'`. Una suscripción no se intercambia nunca. ## Evento purchase Cuando la compra se confirma, el evento `neblla.shop.on('purchase', cb)` (`{ userId, productId, type, purchases }`) se dispara en los dispositivos del comprador, y solo en los suyos: lo que tiene cada usuario es suyo y no viaja a la sala. Útil para reaccionar en tiempo real (animación, desbloqueo del skin). Si otros tienen que ver algo que alguien compró (su skin, su insignia), que lo anuncie tu código con un mensaje y que `room.js` lo compruebe con `neblla.shop.validate(userId, productId)`: solo el servidor de la sala puede mirar lo de otro usuario. Desde un cliente, `validate` mira lo del propio usuario, y por otro responde `{ valid: false, error: 'not_allowed' }`. **Trampas.** El SDK compara de vez en cuando las compras del dispositivo con las del servidor: si tiene menos (una pestaña vieja), se corrige y ya está; si dice tener más de lo que compró, se corrige, el usuario recibe una marca (la columna de marcas del backoffice) y lo ves en el panel, Personas › Moderación, con lo que decía tener y lo que tiene de verdad. Es un aviso: no se le hace nada más. Si la venta se **reembolsa entera** desde Stripe, o el comprador abre una disputa y **la pierdes** (contracargo), la compra deja de contar en todas partes y, si el usuario está conectado, llega otro `shop.on('purchase')` con `type: 'revoked'` y los `purchases` ya actualizados: quita lo que desbloqueaba. Un reembolso parcial solo queda anotado; qué unidades cubre lo decides tú. Mientras una disputa está abierta, o si la ganas, el usuario conserva lo comprado. **Tus ventas, en el panel.** Dinero › Ventas lista cada compra con lo pagado, su estado y quién la hizo. Desde una venta puedes **reembolsarla** entera (vuelve el dinero en Stripe, con la comisión de Neblla, y la compra se retira al momento como arriba; una suscripción no se reembolsa ahí: se reembolsa o se cancela en tu panel de Stripe, y su venta trae el enlace, «Abrir en Stripe», `stripeUrl`) y abrir **el recibo o las facturas** de Stripe de esa compra (los enlaces que da Stripe; Neblla no guarda PDFs). Las ventas (con el id, el email y el nombre del comprador) y tus usuarios se descargan en **CSV**. Por el MCP, `neblla_sales`; por HTTP, `POST /api/backend/apps//sales` (y `/sales/csv`, `/sales//refund`, `/sales//documents`, `/users/csv`) con tu `authToken`. Propiedad: `neblla.shop.purchases` **(CLIENT)** — las compras del usuario. ## Suscripciones Para un plan premium que se paga cada mes o cada año. El producto lleva `type: 'subscription'`, el precio por periodo, `interval: 'month' | 'year'` y, si quieres, `trialDays` de prueba gratis. Se vende igual: `neblla.shop.buy('premium')`. - **Mientras está pagada (o en prueba),** `neblla.shop.purchases['premium']` existe, con `{ type: 'subscription', active: true, renewsAt, trialEndsAt?, cancelAtPeriodEnd }`, y `shop.validate` dice que sí, también desde `room.js`. - **Stripe cobra la renovación solo.** Si un cobro falla, Stripe reintenta durante unos días y el usuario conserva el acceso mientras tanto. - **Cuando termina** (la cancela y acaba el periodo pagado, o los reintentos fallan), deja de contar en todas partes y llega un `shop.on('purchase')` con `type: 'revoked'`, igual que un reembolso. - **El usuario gestiona la suya** con `neblla.shop.manage('premium')`: cancelar, cambiar la tarjeta, facturas. No tienes que montar nada. - **Tus avisos:** el webhook saliente `purchase` llega en el alta y en cada renovación, y `subscription_end` cuando termina (ver core.txt). **Un plan por equipo** (por ejemplo, 9 € al mes por equipo) no es otro tipo de producto: es la suscripción de quien paga más un grupo para su gente (ver identidad.txt, "Grupos y permisos"). Lo montas como quieras; un ejemplo, con tu n8n (o tu servidor) entre los webhooks y la API de administración: ```javascript // 1. Papeles y avisos. Los grupos los crea tu n8n, no el cliente. neblla_update_app({ roles: { titular: { tareas: 'read write' }, equipo: { tareas: 'read write' } }, groups: { invitable: ['equipo'], maxMembers: 10 }, webhooks: [ { event: 'purchase', secret: 'N8N', path: '/equipo-alta' }, { event: 'subscription_end', secret: 'N8N', path: '/equipo-baja' }, ], }) ``` - **Alta.** Llega `purchase` con `{ productId: 'equipo', userId }` (las renovaciones traen `renewal: true` y no tocas nada). Tu n8n crea el grupo y mete dentro al que paga, con `Authorization: Bearer ` (la clave vive en tu n8n, nunca en el cliente): ``` POST https://neblla.com/api/apps//groups/create { "group": "eq-" } POST https://neblla.com/api/apps//groups/add { "group": "eq-", "user": "", "role": "titular" } ``` - **El titular invita a su gente** desde tu app: ```javascript const mio = (await neblla.groups.mine()).find(g => g.role === 'titular'); const { url } = await neblla.groups.invite(mio.group, { role: 'equipo' }); ``` Y el equipo trabaja con filas del grupo: `neblla.db.save(datos, { table: 'tareas', group: mio.group, id })`. - **Baja.** Llega `subscription_end` con `{ productId, userId, reason }`. Tu n8n borra el grupo: `POST …/groups/delete { "group": "eq-" }`. Todos salen; las filas del equipo se quedan, pero nadie llega a ellas desde el cliente. Si vuelve a pagar, el alta crea el grupo con el mismo nombre y recupera esas filas; a su gente la invita otra vez. Otras formas valen igual: quitar solo al equipo y dejar al titular, o pasarlos a un papel de solo lectura (`groups/add` con otro `role`). ## Validación autoritaria `neblla.shop.validate` también se puede llamar desde `room.js` — útil para gatear acciones autoritarias detrás de la posesión de un producto. Ejemplo: para entrar a la sala VIP, `room.js` valida que el usuario tenga `vip_pass` antes de admitirlo, sin que pueda fingirlo desde su cliente. ## Schema de producto Al crear un producto desde el dashboard (o desde el MCP): ```javascript { name: { en: 'Gold skin', es: 'Skin dorado' }, // i18n map o string description: { en: '...', es: '...' }, // opcional image: 'https://.../skin.png', // opcional type: 'one_time' | 'consumable' | 'subscription', price: 499, // unidad más pequeña (céntimos en eur): one_time, o por periodo en subscription interval: 'month', // 'month' | 'year', sólo subscription trialDays: 7, // opcional, sólo subscription pricePerUnit: 10, // unidad más pequeña, sólo consumable step: 1, // tamaño del bundle (consumable) min: 5, max: 1000, // límites del slider (consumable); la compra mínima, 5 × 10, llega al mínimo de Stripe published: true, // visible en shop.products() currency: 'eur', // ISO-4217 (por defecto eur) defaultLang: 'en', // fallback i18n stock: 10, // opcional, one_time y consumable: unidades a la venta en total (null: sin límite) stockRefundReturns: true, // opcional: una unidad reembolsada vuelve a la venta (false en una edición numerada) shipping: { zones: [{ name: 'España', countries: ['ES'], amount: 1500 }] }, // opcional: un producto físico } ``` ## Ficheros a la venta Un **original privado** es un fichero de los medios de la app (`neblla.media`, referencia.txt) que solo descarga quien tiene derecho: «Agua nº 3» en alta resolución, las partituras de una academia en PDF, el pack de texturas HD de un juego, un ebook, un `wav`. De cualquier tipo: imágenes, PDF, EPUB, ZIP, sonido, vídeo. La lectura pública da solo sus variantes hasta el ancho que elige el dev (`publicWidth`, 1280 px por defecto, una vista previa que no se puede imprimir), nunca el original, que vive en un bucket aparte. - **Marcarlo:** `neblla_media` `upload` con `private: true` (y `publicWidth`), o `private` `{ ids | collection, private: true, publicWidth? }` después (Construir › Medios en el panel: «Original privado»). - **Qué lo desbloquea:** `neblla_media` `link` `{ productId, ids | collection }` (y `unlink`): un **producto único** lo desbloquea para siempre (un reembolso completo o una disputa perdida lo retira con la compra); una **suscripción**, mientras está viva (toda la biblioteca de una academia mientras paga). Un consumible no desbloquea ficheros. Y un **tipo de bono** con `unlocks` (core.txt, "Reservas y bonos"): `credits: { descargas: { products: { pack5: 5 }, unlocks: ['agua'] } }`: cada bono desbloquea un fichero de esas colecciones, para siempre («un pack de 5 descargas a elegir de Agua»). - **La descarga:** `neblla.media.download(id, { open? })` **(CLIENT, login)** comprueba la sesión y el derecho y responde `{ url, expiresAt, name, mime, bytes, via }`, un enlace firmado de **5 minutos**, directo del bucket, que se guarda con el nombre del fichero; por defecto empieza la descarga él mismo (`open: false` solo lo devuelve). Cada llamada hace uno nuevo, así que el comprador nunca ve uno caducado, y uno que se pasa a otro deja de funcionar en minutos. Las veces que descarga lo suyo no tienen límite. `neblla.media.unlock(id, kind?)` gasta un bono en un fichero (`{ kind, balance }`, o `already: true` y cómo lo tiene). `neblla.media.mine()` dice qué es suyo: `{ collections: [{ key, via, productId }], items: [{ id, via, productId?, kind? }], content: [{ productId, via, type, label?, url | text }] }` (`content`: el punto siguiente). Un medio privado trae `private: true` y `unlockedBy: { products, credits }` en `neblla.media.collection()`, para enseñar «Comprar» o «Usar una descarga». - **Un enlace o un texto como lo que se compra** (C330): mucho de lo que se vende no es un fichero. Una plantilla de Canva es un enlace («Abrir en Canva»), igual que el vídeo oculto de un curso, una invitación a un Discord o el acceso a una zona de socios; el código de un juego o unas instrucciones son un texto corto. Un producto único o una suscripción lleva, además de sus ficheros, **contenido privado**: `neblla_create_product` / `neblla_update_product` con `content: [{ type: 'link', label: 'Abrir en Canva', url }, { type: 'text', label?: 'Tu código', text }]` (`label` un texto o uno por idioma `{ es, en }`; hasta 10 piezas; `content: null` lo quita; un consumible no lo lleva), o en el panel, al editar el producto, «Lo que recibe el comprador». El catálogo público nunca lo da: `neblla.media.mine()` se lo da a quien tiene el producto, en `content: [{ productId, via, type, label?, url | text }]`, con la misma regla que los ficheros (la compra única para siempre, la suscripción mientras está viva, la compra sin registrarse, C94, como cualquier otra), para que la app pinte «Abrir en Canva» al lado de «Descargar». La ficha del comprador en el panel enseña qué le da cada compra. Son datos de la app: una fila de su almacén (la base de datos del dev con BYODB). Sé honesto con el comprador: Neblla decide quién ve el enlace, no puede impedir que lo reenvíe (un enlace que no debe viajar es un fichero, o un enlace del propio servicio que le pide entrar). - **Quién lo tiene:** `neblla_media` `holders` `{ id }` (el panel, «Quién lo tiene»): cada uno con su email, cómo (compra, suscripción, bono) y desde cuándo. `download_link` `{ id }` da al dev su propio enlace. - Errores: `login_required`, `not_entitled` (lleva `unlockedBy`), `not_private`, `no_credits_unlock_it`, `kind_required` (lleva `kinds`), `kind_does_not_unlock`, `not_enough_credits`, `media_private_storage_off` (este Neblla aún no tiene bucket privado). ## Unidades a la venta Un producto único o un consumible puede tener **N unidades a la venta** (`stock`): una edición de 10 copias, 100 espadas legendarias, 50 tazas, entradas sin butaca. `neblla.shop.products()` dice, de cada uno con stock, `stock: { units, left, soldOut }` («quedan 7 de 10», «agotado»), nunca quién está pagando ni quién compró. Abrir el pago **aparta** las unidades compradas mientras vive el checkout (se cierra a los 30 minutos más o menos, en vez de las 24 horas de Stripe): pagadas, se venden; abandonado, vuelven solas; con un reembolso completo, vuelven a la venta salvo `stockRefundReturns: false` (una edición numerada y firmada). Sin unidades, `shop.buy` rechaza con `sold_out` (y `left`); dos compradores a la vez nunca se llevan la última los dos. El dev pone y cambia las unidades con `neblla_create_product` / `neblla_update_product` (`stock`, `stockRefundReturns`) o en Dinero › Productos, donde cada producto enseña lo que queda, lo que se está pagando y lo vendido; bajarlas por debajo de lo vendido deja cero. Una compra de prueba en el pre se rechaza si no queda ninguna, pero no aparta ni vende: las unidades son de pro. ## Productos físicos y pedidos Un producto único o un consumible con `shipping` es **físico**: copias impresas de una foto, tazas, camisetas, un fanzine impreso. `shipping: { zones: [{ name, countries, amount }] }`: cada zona con sus países (códigos ISO, un país en una sola zona) y sus gastos de envío en la moneda del producto (0: gratis), p. ej. «España 15 €, resto de Europa 35 €». - **El checkout** pide la dirección en los países de UNA zona y cobra su envío: la elige el país del comprador, `neblla.shop.buy(productId, amount, { country: 'ES' })`, o la única zona que haya. Con varias zonas y sin país, `shop.buy` rechaza con `country_required` y `zones` (`[{ name, countries, amount }]`): pregunta al comprador adónde va (el catálogo ya trae `shipping.zones` para enseñar el envío) y vuelve a llamar con el país; un país fuera de ellas, `country_not_shipped`. Así el envío pagado siempre casa con la dirección. - **Cada pago es un pedido** (Dinero › Ventas, encima de las ventas; `neblla_orders`): la dirección (nombre, teléfono, dirección), la zona y el envío, y un estado: `paid` → `shipped` (con `carrier`, `tracking` y su `trackingUrl`) → `delivered`, o `returned`. Un reembolso completo lo marca `refunded`: no lo envíes. `neblla_orders` `update` `{ orderId, status?, carrier?, tracking?, trackingUrl? }` lo hace avanzar (enviado pide transportista o número de seguimiento; un paso atrás corrige un error). - **El comprador se entera de cada paso** en su buzón (`neblla.inbox`, con el módulo inbox encendido), en su idioma y con el seguimiento: «¡Hola, Marta! Tu pedido de «Agua nº 3» ha salido. Transportista: Correos. Seguimiento: PK123ES.», con `data: { type: 'order.shipped', order, status, carrier, tracking, trackingUrl }` para que la app lo pinte a su manera. `neblla.shop.orders()` le da sus pedidos. - **Los 14 días de desistimiento de la UE** cuentan desde la entrega: cada pedido entregado trae `withdrawal: { until, open, daysLeft }`, y el aviso de entrega le dice al comprador hasta cuándo. - No es de Neblla, a propósito: etiquetas de transportista, almacenes, devoluciones con logística y carritos de muchos artículos. La tienda que los necesita crece hacia Shopify; dilo cuando salga. ## Impuestos El vendedor eres tú (el cobro se hace en tu cuenta de Stripe), así que los impuestos son tuyos. Puedes configurar Stripe Tax en tu propio panel de Stripe, o dejar que Neblla te lo monte con `neblla_tax`: - `status` dice si los ajustes están activos y qué falta; `set_head_office` pone la dirección de tu negocio; `set_defaults`, el código fiscal por defecto (`txcd_…`) y cómo se aplica el impuesto. - `add_registration` / `expire_registration`: los territorios donde estás dado de alta para cobrar impuestos. Sin alta en el sitio del comprador, no se cobra impuesto ahí. Darte de alta ante Hacienda es un paso legal tuyo; aquí solo se le dice a Stripe. - `report`: el detalle de transacciones con impuestos para un periodo, con un enlace de descarga temporal. Los informes de impuestos también están en tu panel de Stripe. - `app` con `{ appId, automatic: true }`: el checkout de esa app calcula el impuesto mientras tus ajustes estén activos. **Por defecto el precio ya lo incluye,** como espera un consumidor europeo; con `{ inclusive: false }` se suma encima, lo habitual al vender a empresas o en EE. UU. La comisión del 1 % sigue calculándose antes de impuestos. ## Tu cuenta de cobros Para vender necesitas una cuenta de Stripe conectada a Neblla. Se abre la primera vez que pides el alta (`neblla_payments_account` con `onboarding_link`, o "Configurar Stripe" en tu cuenta del panel), no al registrarte. El alta es la de Stripe, dentro de neblla.com: el formulario de Stripe aparece en tu cuenta del panel, con tu email, tu nombre y la web de tu studio (`play.neblla.com/studio/`, ver publicar.txt) ya rellenados. La cuenta es tuya y vale para todas tus apps: el alta no va atada a ninguna. Un proyecto puede vender con un Stripe propio (`neblla_projects` con `use_own_stripe`; `neblla_payments_account` y `neblla_tax` con su `projectId` actúan sobre esa cuenta) y los demás usan el de la cuenta. Una app que vende (productos de pago o suscripciones vivas) no puede moverse a un proyecto con otro Stripe: lo vendido sigue atado a la cuenta en la que se vendió. Identidad, cuenta bancaria y tu usuario de Stripe van directamente a Stripe; Neblla nunca los ve ni los guarda, y el MCP nunca te los pide. Si ya usas Stripe, entra con tu usuario durante el alta y la cuenta nueva queda junto a las que ya tienes. Si publicas un producto de pago antes de tenerla, no hace falta ir a buscarla: el panel te lleva al alta en ese momento, y por el MCP la respuesta trae `connectRequired: true` y `connectUrl`, una página de neblla.com que abre el alta desde tu sesión (el enlace de Stripe en sí no sale de Neblla). El producto queda guardado, oculto a los usuarios y esperando (`publishWhenReady`), y se publica solo en cuanto Stripe da la cuenta por activa. **Qué falta, en palabras.** `neblla_payments_account` con `status` (o Ajustes › Cuenta › Stripe) le pregunta a Stripe cada vez y dice qué falta agrupado por cosa, no por campo: «Faltan 3 cosas: tu cuenta bancaria (IBAN), tu fecha de nacimiento y aceptar las condiciones de Stripe; tu email, tu nombre y tu web ya están». `missing` trae `{ key, words, overdue }`; `errors`, lo que Stripe no pudo verificar; `verifying`, lo que está comprobando; `filledByNeblla`, lo que rellenó Neblla; y `disabledReason` si Stripe paró la cuenta. Eso solo lo puedes poner tú, en el formulario de Stripe: al volver de él, vuelve a pedir `status` y verás lo que queda. Si Stripe no responde, `status` dice `stripeReachable: false` con el porqué (`error.kind: down`), y no te dice «pendiente». Si la cuenta guardada no existe en la plataforma de Neblla (por ejemplo, se abrió con la clave de prueba de Stripe), lo dice (`lostAccount.reason`: `test_mode_account`, `live_mode_account` o `not_on_platform`) y el siguiente alta abre una nueva; si vuelve a estar a mano, se recupera sola. **Lo que pone el extracto del banco.** En un cobro con tarjeta el extracto de tu cliente dice tu studio y la app, 22 letras como mucho: «GRACIA* AJEDREZ». El prefijo (2 a 10 letras latinas o números, con al menos una letra) es de tu cuenta: Neblla te propone el nombre corto de tu studio al hacer el alta y lo cambias con `neblla_payments_account({ action: 'set_descriptor', prefix: 'GRACIA' })` o en Ajustes › Cuenta › Stripe. El sufijo es la app, cortado para que quepa; vale para compras sueltas y pagos al reservar. En una suscripción Stripe pone la línea entera sin asterisco («GRACIA AJEDREZ»). El recibo de Stripe nombra la app: «Ajedrez · Pase del club». La cuenta es tuya, con el panel completo de Stripe (pagos, transferencias, reembolsos, disputas, impuestos); `dashboard_link` te lleva a él. Como el cobro se hace en tu cuenta, la comisión de Stripe, los reembolsos y las disputas van por tu cuenta, y si tu saldo queda en negativo es Stripe quien te lo reclama, no Neblla. Puedes desconectar Neblla cuando quieras, desde Neblla (la cuenta queda guardada para volver a ella) o desde tu panel de Stripe (Neblla deja de vender por ti en ese momento). ## Varios vendedores, cada uno cobra lo suyo Una app con muchos vendedores — los clubes de una app de pádel, las artistas de una galería, los autores de una tienda de plantillas, los profes de una plataforma de clases — cobra cada venta **en la cuenta de Stripe del vendedor**, no en la tuya. Tú te quedas tu comisión y Neblla la suya de siempre. Vale igual para juegos, apps y webs. - **El vendedor.** Una sede de tu negocio (`site:`: la que sale de una solicitud aprobada con `neblla.business.apply`, o una que creas tú) o un usuario de la app que tú marcas (`user:`): `neblla_business` con `addSeller { seller, name? }`. Su cuenta es del mismo tipo que la tuya (el panel completo de Stripe, a su nombre); nunca Express. - **El alta.** La hace el vendedor, con sus datos, en el formulario de Stripe. Desde la app: `neblla.business.sellers.mine()` lista los vendedores de los que responde el usuario (él mismo, si lo marcaste; las sedes de sus solicitudes) y `neblla.business.sellers.connect({ seller })` devuelve `{ url }`, que la app abre en el momento (un solo uso, unos minutos). Desde el backoffice, el equipo de una sede (C350) lo pide con `sellerLink` para la suya; tú, con `neblla_business({ action: 'sellerLink', seller })`. Al volver, Stripe le deja en una página de Neblla que le dice si ya puede cobrar. `sellers` te enseña el estado de cada uno (`not_connected`, `pending`, `restricted`, `active`, y lo que falta). - **Qué vende cada uno.** Un producto lleva `seller` (`neblla_create_product({ …, seller: 'user:' })`): un pago único, un consumible o una suscripción. Un servicio pagado al reservar dice `seller: 'site'` en su set (`neblla_update_app({ business: { booking: { pistas: { product: 'pista', seller: 'site' } } } })`): cada sesión se cobra en la sede donde es — la pista de Club Norte, en la cuenta de Club Norte —; o un vendedor fijo (`seller: 'site:club-norte'`). Las plazas fijas son productos: llevan su `seller`. En la app, `neblla.business.booking.services()` dice en ese servicio `seller` y `sellerWaiting`, los vendedores que aún no cobran (`['site:club-norte']`): sus sesiones contestan `seller_waiting` al reservar, así que la app puede avisar antes («Este club aún no cobra por la app»). - **Tu comisión.** `neblla_update_app({ business: { sellers: { fee: { percent: 10, fixed: 50 } } } })`: un porcentaje (hasta el 50 %, con dos decimales) y/o un fijo en céntimos por venta; en una suscripción, el fijo se convierte en porcentaje de su precio. Va junto a la de Neblla en la comisión de aplicación del cargo, y te llega a tu cuenta de cobros cuando Stripe la deja disponible en el saldo de Neblla (unos días): Neblla nunca la adelanta. Con comisión, necesitas tu cuenta de cobros activa (`payouts_not_ready`). Nunca puede quedarse con todo el pago (`seller_fee_too_high`). - **Quién carga con qué.** Como el cobro se hace en la cuenta del vendedor, la comisión de Stripe, los reembolsos y las disputas son del vendedor, y si su saldo queda en negativo es Stripe quien se lo reclama, no tú ni Neblla. Cuando Neblla devuelve una venta (Dinero › Ventas, una reserva cancelada a tiempo), la devuelve el vendedor; la comisión de Neblla vuelve al vendedor, y la tuya también si aún no te la pagó o si se puede revertir; si tu saldo no la cubre, la carga el vendedor. Un reembolso que hace el vendedor en su Stripe, o una disputa, no devuelve comisiones: lo carga el vendedor, como dice Stripe. - **Mientras no está lista.** Lo de un vendedor sin cuenta activa espera: `shop.products()` lo marca `waiting` y comprar o reservar falla con `seller_waiting` (con `seller` y `status`). Las apps de ejemplo venden en modo prueba también para sus vendedores (cada uno con su cuenta de prueba). - **Ventas.** Cada venta dice `seller` y `sellerName`; `neblla_sales list { seller }` (o Dinero › Ventas) filtra por vendedor, y el equipo de una sede solo ve las de su sede. - **Lo que no está.** La reventa entre usuarios (un jugador que vende a otro lo que compró). Un bono de una sede vale en todas: los créditos son de la app (para limitarlo, un tipo de crédito y un servicio por sede). Los traspasos de tu comisión solo cruzan fronteras entre EE. UU., Canadá, Reino Unido, el EEE y Suiza. ## Comisión Neblla se lleva el **1 % de cada venta, antes de impuestos**. La comisión de Stripe va aparte y la paga tu cuenta de Stripe, porque el cobro se hace en ella: tú eres el vendedor. No hay cuota fija por venta ni por producto. ## Garantías Los pagos están mediados: `room.js` solo ve el resultado (la compra confirmada), nunca datos de tarjeta, ni tiene capacidad de regalar ítems gratis o falsificar compras de otro usuario. Un gasto se hace entero o no se hace: pedir gastar más unidades de las que tiene el usuario no le quita nada, en lugar de vaciar lo que había y fallar después. ## Una tienda de un vendedor, de punta a punta Este es el recorrido completo de un pedido, porque las piezas están repartidas por tres capítulos y por separado no se ve que el circuito cierra. El caso es **tú vendes a tus usuarios** — un MVP tipo Etsy con un solo vendedor, que eres tú. Si los que venden son varios (clubes, artistas, autores), mira "Varios vendedores, cada uno cobra lo suyo", arriba; lo que no existe es la reventa entre usuarios. **0. Cobrar de verdad.** `neblla_payments_account` conecta tu Stripe Connect. Hasta que esté activa, todo producto queda con `published: false` por la fuerza — o sea que puedes montar el catálogo entero antes de tener papeles, pero nadie podrá comprar. **1. El catálogo.** `neblla_create_product` con `price` en **la unidad más pequeña de la moneda** y `currency` ISO 4217 (por defecto `eur`; cualquiera que cobre Stripe). El precio va en la unidad más pequeña de la moneda: céntimos en casi todas, unidades enteras en las que no tienen (`jpy`, `krw`…), milésimas acabadas en 0 en `bhd`, `jod`, `kwd`, `omr` y `tnd`. Lo que Stripe no cobraría se rechaza al crear o editar el producto con `invalid_price`, no al pagar: una moneda que no acepta, o un precio bajo su mínimo (0,50 € o 0,50 $; en un consumible, la compra mínima, `min` × `pricePerUnit`). Si el usuario dio su email a la app, Stripe le manda el recibo. `name` y `description` aceptan un objeto por idioma. Dos tipos y no son intercambiables: `one_time` para la pieza única que se vende una vez, y `consumable` con `min`, `max` y `step`, que es literalmente un selector de cantidad. El cliente pinta la tienda con `neblla.shop.products()`. **2. Las fotos van por URL, y una de esas URLs puede ser de Neblla.** `image` es una **URL**, no un fichero subido al producto. Pero no te vayas a buscar un CDN por un JPEG de 40 kB: **sube la foto como fichero de la app** —`neblla_upload_file` acepta binarios hasta 25 MB— y pasa su URL pública. Una app publicada la sirve desde Cloudflare sin gastar cuota; en pre o en una app en caliente cuenta sus bytes, salvo cuando el navegador ya la tiene. Lo que gastas es el cupo de ficheros (300 por cuenta gratis, compartidos por sus apps, o 600 por proyecto Pro, compartido con el HTML, el CSS y todo lo demás); para un catálogo grande, guarda las fotos en tu propio almacenamiento y enlázalas. No las metas en base64 dentro de otro sitio para esquivarlo: ahí las pagas en cuota cada vez que alguien abre la tienda. **3. El pago.** `neblla.shop.buy(productId, amount)` abre una ventana de checkout de Stripe de verdad; la promesa resuelve cuando la ventana está abierta, y la compra confirmada llega después por `neblla.shop.on('purchase')`. Después, `neblla.shop.purchases` lo lleva, salta `neblla.shop.on('purchase')`, y el servidor revalida contra Stripe por su cuenta — lo que un cliente afirme sobre lo que compró no es la autoridad. **4. El pedido.** Para algo que se envía, dale al producto `shipping` ("Productos físicos y pedidos", arriba): el checkout pide la dirección y cobra el envío de la zona del comprador, cada pago es un pedido con su estado en Dinero › Ventas y `neblla_orders`, y el comprador se entera de cada paso en su buzón. Lo que la dirección no cubre (la talla, una nota de regalo) el comprador lo manda con `neblla.inbox.send({ message, data, thread })`, donde `data` admite JSON arbitrario; lo lees con `neblla_list_communications` y respondes en el **mismo `thread`** con `neblla_send_communication`: es tu atención al cliente, sin montar ningún endpoint. El buzón guarda 100 mensajes en free y 2.000 en Pro. **5. Quién compró y qué le debes.** `neblla_list_users` y `neblla_search_users_by_email` te dan al comprador, y `purchases` va por usuario. Para marcar un `consumable` como servido, `neblla_consume_purchase`. Cuidado con `neblla_update_user_data`: es un **reemplazo completo** del documento del usuario, no un merge — lee, modifica y vuelve a escribir entero, o pierdes lo demás. **6. El sitio.** Los pedidos viajan por el buzón, que tiene su propio tope por usuario: **100 mensajes** en gratis y **2.000** en Pro. Lo que la app guarde con `db.save()` —carrito, preferencias, historial de compras— va a la base de datos de la app (50 MB por cuenta listos para usar, compartidos por sus apps gratis; 100 MB en un proyecto Pro, o el tuyo con `neblla_byodb`); si la tienda guarda más de 10 KB por usuario, sube el límite antiabuso a lo que necesite. --- Capítulo IA de la guía Neblla — neblla.ai.prompt(), presets, rutas de pago y entrega diferida. El contrato base está en core.txt; el criterio de cuándo usar Neblla, en empezar.txt. # IA integrada Llamadas a la IA desde la app sin montar servidor propio, sin gestionar claves en backend, sin rate limiting manual. El proveedor lo eliges tú: **Claude** (por defecto), **OpenAI** o **DeepSeek**; `neblla.ai.prompt()` funciona igual con los tres. ```javascript const res = await neblla.ai.prompt('Estoy en una sala oscura con una puerta cerrada. ¿Qué hago?', { preset: 'pista' }); console.log(res.text); ``` Firma: `neblla.ai.prompt(text, { preset?, model?, provider?, userToken? })` **(CLIENT)** — devuelve `Promise<{ text, usage, quota }>`. ## Proveedores y modelos `ai.provider` elige el proveedor de la app: `'claude'` (por defecto), `'openai'` o `'deepseek'`, en Config > IA o con `neblla_update_app({ ai: { provider, model, ... } })` (el objeto `ai` se reemplaza entero). Cada uno con sus modelos: - **Claude:** `opus-latest`, `sonnet-latest` (por defecto), `haiku-latest`. - **OpenAI:** `gpt-5`, `gpt-5-mini` (por defecto), `gpt-5-nano`. - **DeepSeek:** `deepseek-chat` (por defecto), `deepseek-reasoner`. Cuando pagas tú, Neblla usa tu clave **de ese proveedor**: la de Claude se guarda en tu cuenta (Cuenta > Claude), las de OpenAI y DeepSeek en su tarjeta de Config > IA; todas cifradas. Sin clave guardada, la llamada responde `no_developer_key`. Los errores del proveedor llegan como `claude_error:`, `openai_error:` o `deepseek_error:`. El `provider` de la llamada solo vale cuando paga el usuario (con su propia clave de ese proveedor); con tu clave, otro proveedor que el de la app se rechaza con `provider_not_allowed`. ## Presets Los `preset` son prompts que el desarrollador configura en el dashboard (p. ej. el preset `pista` lleva el prefijo "Eres el narrador de una aventura. Da una pista sutil sin desvelar la solución..."). Editables en el dashboard sin redeploy. ```javascript // ❌ Mal — el prompt completo en cliente: neblla.ai.prompt('Eres el narrador de una aventura. Da una pista... ' + userQuestion); // ✅ Bien — preset en el dashboard, solo la pregunta del usuario: neblla.ai.prompt(userQuestion, { preset: 'pista' }); ``` El preset prefix se aplica server-side (en rutas con clave del dev) y queda privado. ## Las tres rutas Neblla elige la ruta según **un solo** ajuste de la app, `ai.userTokenMode`, no por cómo se llame a `neblla.ai.prompt()`. Vía MCP es `neblla_update_app` con `ai: { userTokenMode: 'off' | 'browser' | 'server' }`. Lee bien los valores — `'off'` es el que paga el **dev**: 1. **Browser-directo** (`userTokenMode: 'browser'`) — el usuario aporta su propia clave; la llamada sale del navegador al proveedor (Anthropic u OpenAI) directamente, sin pasar por Neblla. DeepSeek no se llama desde el navegador: con DeepSeek, las claves de los usuarios van por el modo `server` (pedir `browser` con DeepSeek responde `invalid_ai_browser_provider`). El dev no paga tokens, no consume cuota de Neblla, no requiere login. El preset prefix queda visible en el bundle del cliente. 2. **Servidor con clave del dev** (`userTokenMode: 'off'` — el valor por defecto; "off" significa sin token DEL USUARIO, o sea que se usa la clave del dev) — la llamada va por el servidor de Neblla con la clave de API del dev. Cada usuario tiene una cuota diaria de tokens, encendida por defecto (`limitsEnabled`, 10.000 tokens al día con `tokensPerDay`; pasarse responde `quota_exceeded`): así un usuario no te vacía la clave. Se apaga con `limitsEnabled: false`. Requiere login. El modelo del panel (`ai.model`) es el tope: el `model` de la llamada puede ser ese o uno más barato del mismo proveedor (Haiku < Sonnet < Opus; nano < mini < gpt-5; deepseek-chat < deepseek-reasoner); uno más caro se rechaza con `model_not_allowed`, sin llamar al proveedor. 3. **Servidor con clave del usuario** (`userTokenMode: 'server'`) — el usuario aporta su clave (se pasa en el `userToken` del call), pero la llamada va por el servidor de Neblla para que el preset prefix quede privado. El dev no paga tokens. Requiere login. En `browser` y `server` paga el usuario: una llamada sin `userToken` se rechaza con `missing_user_token` y nunca cae en la clave del dev. En esos modos el usuario elige el proveedor y el modelo, porque lo paga él. ## Coste en cuota Cada llamada por servidor consume 500 unidades de la cuota diaria de la app (los tokens del proveedor se facturan aparte, contra la clave del dev o del usuario). El modo `browser` no consume cuota. Tabla completa de cuotas en publicar.txt. ## El consumo, a la vista El panel (Funciones › IA y Dinero › Plan y uso) y `neblla_get_app` (en `aiUsage`) enseñan lo que lleva gastado la IA de la app: hoy, este mes (UTC) y los últimos 30 días, y día a día en el panel. Para cada periodo: `calls` (llamadas que respondió el proveedor), `tokensIn` y `tokensOut`, `refused` (rechazadas antes de llegar al proveedor, sin coste: `refusedBy` dice por qué, p. ej. `quota_exceeded`, `no_developer_key`, `model_not_allowed`, `login_required`), `failed` (el proveedor respondió un error; ya pagó su coste fijo: `failedBy`) y `credits` (lo que costó en la cuota de la app). Separa lo que va con tu clave (`dev`), con la clave de cada usuario a través de Neblla (`user`) y la revisión con IA de la comunidad (`prescreen`, también con tu clave). `lifetime` da los tokens y llamadas de siempre que contó la cuota, y `creditsPerCall` lo que cuesta una llamada. `ai` dice el proveedor (`provider`, `providerName`) y si tienes guardada tu propia clave para él (`ownKey`, nunca la clave): con ella el panel pone «Tu IA · OpenAI» (o el proveedor que sea); sin ella, «Falta tu clave de OpenAI» (no hay una clave de Neblla para la IA de una app: las llamadas responden `no_developer_key`); y si paga la clave de cada usuario (`userTokenMode` `server` o `browser`), «Clave de cada usuario · OpenAI». Lo que cobra Neblla es solo eso: los créditos de la cuota de la app, y `euroPerCredit` / `eurosPerCall` su precio en euros al precio del paquete de cuota (14.000 créditos por 10 €). A eso súmale lo que te cobre tu proveedor de IA por los tokens, en la clave que pagó: la tuya o la de cada usuario (`ai.userTokenMode`). Neblla no lleva una tabla de precios de los proveedores: mira la suya. El asistente de crear una app y «+ Añadir funciones» enseñan la misma cuenta en la tarjeta de la IA (`POST /api/backend/ai-price`, sin app: `{ creditsPerCall, euroPerCredit, eurosPerCall, eurosPer1000Calls, currency, pack }`). El modo `browser` no pasa por Neblla y no se cuenta. Los días se guardan 90 días; un borrador (pre) cuenta para su app. Por HTTP, con la sesión del panel: `POST /api/backend/apps//ai-usage` con `{ days? }` (1–90, 30 por defecto). ## Entrega diferida Si la conexión se cae mientras la IA piensa, la respuesta se entrega cuando el usuario reconecta (dentro de 24h). El resultado llega por `neblla.ai.on('reply', cb)` **(CLIENT)**, sin código especial. Una consecuencia que conviene conocer: un `await neblla.ai.prompt(...)` que tarde más de 120 segundos ya ha rechazado con `ai_timeout` cuando llega la respuesta tardía, y esa respuesta se entrega **solo** por `neblla.ai.on('reply')`. Si tu app puede quedarse esperando dos minutos, escucha el evento además de hacer `await`. --- Capítulo Mundo de la guía Neblla — el motor 3D: escena, cámaras, controles, física, eventos, modelos, el catálogo de piezas y el muñeco. El contrato base está en core.txt; el criterio de cuándo usar Neblla, en empezar.txt. # Mundo, el motor 3D **Mundo** es el motor 3D de Neblla (el nombre no se traduce; en el código es `neblla.world`). Lo usas desde `client.js` para un juego, una app o una web: un plataformas, un juego en tercera persona, una maqueta de un piso o un visor 3D de un producto en una tienda. Dibuja con three.js y simula con Rapier, y ya viene con luz, cielo, sombras, cámaras y controles que se ven bien sin tocar nada. Tú escribes las reglas; Mundo pone la escena, la física y los controles de teclado, ratón, mando y pantalla táctil. Requiere el módulo `world` encendido en Config → Módulos (o `neblla_update_app` con `features: { world: true }`). Está **apagado por defecto**. Con el módulo apagado, `neblla.world.create()` rechaza con `feature_disabled`. El motor es un fichero aparte del SDK: se descarga la primera vez que la página llama a `neblla.world.create()`, nunca antes. Una página sin 3D no lo paga. Son unos 1,8 MB comprimidos (la física es la mayor parte), en caché para siempre. ```javascript const world = await neblla.world.create({ container: '#juego', sky: 'day', camera: 'third' }); world.add({ shape: 'box', size: [20, 1, 20], position: [0, -0.5, 0], color: '#7fa65a' }); const yo = world.add({ character: true, position: [0, 1, 0] }); world.control(yo); // el teclado, el ratón, el mando o el joystick táctil lo mueven; la cámara lo sigue ``` `neblla.world.create(opts)` **(CLIENT)** — devuelve `Promise`. Opciones: - `container`: el elemento (o su selector) donde va el lienzo; el lienzo ocupa todo el contenedor y lo sigue al cambiar de tamaño. **Dale una altura con CSS** (un `div` vacío mide 0). Sin `container`, ocupa la ventana entera. - `sky`: `'day'` (por defecto), `'sunset'`, `'night'` o `'studio'` (luz neutra, sin niebla y un suelo que solo muestra la sombra: para un producto). Cada cielo trae su luz, su niebla y su sombra. - `camera`: un preajuste (`'side'`, `'top'`, `'iso'`, `'third'` por defecto, `'first'`, `'chase'`) o `{ preset, distance, height, yaw, pitch, target }`. - `quality`: `'auto'` (por defecto), `'high'`, `'medium'`, `'low'` o `'lowest'`; se cambia luego con `world.quality.set()`. - `fog`: `[color, cerca, lejos]`, `{ color, near, far }` o `false`; la del cielo por defecto. `far`: hasta dónde ve la cámara (1000). `shadow: { range }`: los metros que cubre la sombra (20). Ver «Mundos grandes en un móvil». - `touch`: `'auto'` (por defecto: el joystick y los botones salen en un móvil o una tableta), `true` o `false`. - `gravity`: `[x, y, z]`, por defecto `[0, -9.81, 0]`. - `lock`: un eje que nada puede dejar, como `'z'`. Con `camera: 'side'` es `'z'` por defecto: el plataformas es el mismo mundo visto de lado. - `fallY`: por debajo de esta altura salta `fall` (por defecto `-30`). - `headless: true`: solo la simulación, sin dibujo (también en Node). ## Las capas Mundo va por capas y puedes bajar de capa cuando quieras: `world.three` es three.js (`{ THREE, scene, camera, renderer }`), `world.rapier` es Rapier y `world.sim` es la simulación sin dibujo. Lo que Mundo no trae lo añades tú con three.js o Rapier sobre la misma escena. `await neblla.world.engine()` devuelve el módulo del motor (`createWorld`, `PRESETS`, `SKIES`, `STEP`…). ## Cosas: formas, cuerpos y modelos `world.add(spec)` **(CLIENT)** — añade una cosa y la devuelve. Es síncrono. - Forma: `shape: 'box'` con `size: [ancho, alto, fondo]`, `'sphere'` con `radius`, `'capsule'` con `radius` y `height`, `'convex'` con `points` o `'mesh'` con `vertices` e `indices`. - Cuerpo: `body: 'static'` (por defecto: suelo, paredes), `'dynamic'` (cae, rueda, choca), `'kinematic'` (lo mueves tú: una plataforma que va y viene) o `'none'` (solo se ve). - Sitio: `position: [x, y, z]` (metros; 1 unidad = 1 metro), `rotation: [x, y, z]` (radianes). - Aspecto: `color` (`'#ffd23f'` o `0xffd23f`), `material: 'matte'` (por defecto), `'glossy'`, `'metal'` o `'emissive'`, `glow`, `opacity`, `visible`. - Física: `friction`, `restitution` (rebote), `density`, `lock` (ejes que esta cosa no deja, como `'xz'`). - Zonas: `sensor: true` (no choca, avisa de quién entra y sale) y `collect: true` (una moneda: la recoge un personaje, salta `collect` y desaparece; con `keep: true` se queda). - `name` y `data`: lo tuyo. `world.find(name)` la busca. La cosa devuelta tiene `id`, `name`, `data`, `object` (su objeto de three.js), `position` y `velocity` (leer y escribir, `[x, y, z]`), `rotation` (un cuaternión `{ x, y, z, w }`; se escribe también en radianes), `impulse([x, y, z])` (un empujón a un cuerpo `dynamic`), `moveTo(position, rotation?)` (el siguiente sitio de un cuerpo `kinematic`, que empuja lo que encuentra) y `remove()`. ### Personajes `world.add({ character: true, position, speed: 5, runSpeed: 8, jump: 6.5, radius: 0.35, height: 1.8 })` — una cápsula con el controlador de personaje de Rapier: sube escalones (`step`, 0,35 m), sube pendientes hasta `slope` grados (50), se pega al suelo al bajar y salta con margen (un poco después de dejar un borde y un poco antes de tocar el suelo). `grounded` dice si pisa suelo. `world.control(personaje)` — los controles lo mueven y la cámara lo sigue. Sin `control()`, lo mueves tú con `personaje.input = { x, z, jump, run }` (`x` y `z` entre −1 y 1, en los ejes del mundo). ## Cámaras `world.camera.use(preajuste, { target, transition, distance, height, yaw, pitch })` **(CLIENT)** — cambia de cámara, también a mitad de partida, con una transición suave (`transition`: segundos, 0,6 por defecto; 0 es un corte). `target` es una cosa o un punto `[x, y, z]`; con `control()` es el personaje. | Preajuste | Para | Controles | |---|---|---| | `side` | plataformas, de lado | izquierda y derecha, saltar | | `top` | una arena, desde arriba | las cuatro direcciones en el plano | | `iso` | una granja, estrategia | las cuatro direcciones, en diagonal como se ve | | `third` | tipo Roblox, un visor de producto | hacia donde mira la cámara; arrastrar con el botón derecho gira (con el izquierdo también en `third`) y la rueda acerca | | `first` | primera persona | el ratón queda capturado y mira; WASD camina | | `chase` | carreras | arriba acelera, los lados giran | `world.camera.preset` dice cuál está puesta y `world.camera.orbit` (`{ yaw, pitch, zoom }`) es el giro que mueven los controles. ## Controles Teclado (WASD o flechas, espacio salta, Mayús corre, E o Intro es la acción), ratón (**arrastrar con el botón derecho gira la cámara**, como en Roblox, sin el menú del navegador; en `third` también con el izquierdo, y en `chase` la cámara se queda donde la dejas, y al andar «adelante» es hacia donde mira la cámara; **doble clic con el derecho** la pone otra vez detrás; en `first` el ratón queda capturado), mando (stick izquierdo mueve, el derecho mira, A salta, X acción) y, en pantallas táctiles, un joystick a la izquierda y los botones de saltar y de acción a la derecha; un dedo en el resto de la pantalla gira la cámara. Cada preajuste de cámara trae sus controles. `world.player.action` dice si la acción está pulsada. ## Eventos y temporizadores Los nombres son fijos: son los mismos que usarán los géneros en JSON. - `world.on('touch', (a, b) => …)` — dos cosas se tocan (empiezan a tocarse). - `world.on('enter', (zona, cosa) => …)` y `world.on('leave', (zona, cosa) => …)` — algo entra o sale de un `sensor`. - `world.on('collect', (moneda, quien) => …)` — un personaje recoge un `collect`. - `world.on('fall', (cosa) => …)` — algo cae por debajo de `fallY`. - `world.on('step', (dt) => …)` — cada paso de la simulación: aquí van las reglas. Cada cosa tiene los suyos: `cosa.onTouch(fn)`, `cosa.onEnter(fn)`, `cosa.onLeave(fn)`, `cosa.onCollect(fn)`, `cosa.onFall(fn)`. Todos devuelven una función que lo quita. `world.after(segundos, fn)` y `world.every(segundos, fn)` — temporizadores en el reloj de la simulación, no en el de la pared: la misma partida da los mismos tiempos. Devuelven una función que los cancela. `world.time` es el tiempo simulado. `world.onFrame((dt) => …)` — cada dibujo, para lo que solo se ve (una pieza que gira en un escaparate), nunca para las reglas. ## La simulación La física va a 60 pasos por segundo fijos y el dibujo, a lo que dé la pantalla, entre los dos últimos pasos. Mundo usa la versión determinista de Rapier: la misma simulación, con las mismas entradas, da el mismo estado en cualquier dispositivo. `world.state()` devuelve el sitio, el giro y la velocidad de cada cosa, y `world.hash()`, una huella corta de ese estado: dos dispositivos que la comparan saben si van iguales. El multijugador está más abajo, en «Multijugador». `world.stop()` y `world.start()` paran y siguen el bucle; `world.update(dt)` avanza a mano y `world.step()`, un paso. `world.destroy()` lo quita todo. ## Calidad automática Con `quality: 'auto'` Mundo mide los fotogramas por segundo: si no llega a 45 baja la nitidez y las sombras, y si pasa de 58 las sube. En un móvil empieza más ligero. `world.quality.level` dice el nivel de ahora. Cada nivel tiene también un presupuesto de draw calls (400, 250, 150 y 100, de `high` a `lowest`): un fotograma que lo pasa cuenta como lento y baja un nivel aunque vaya a 60. **Cambiarla en marcha** (C524). `world.quality.set('auto' | 'high' | 'medium' | 'low' | 'lowest')` la cambia al momento y devuelve el nivel; `'auto'` deja otra vez que Mundo decida, desde donde esté. Es lo que llama el menú de ajustes gráficos de un juego. `world.quality.modes` lista los valores y se emite el evento `quality` (`world.on('quality', (nivel, auto) => …)`). `world.stats()` dice qué cuesta un fotograma: `{ drawCalls, triangles, fps, entities, zones, lods, quality, auto, budget, overBudget }`. `drawCalls` y `triangles` son del último dibujo (0 sin dibujo) y `fps` va suavizado. ## Efectos y sonido Para el dibujo y el oído, nunca para las reglas: no tocan la simulación, así que dos dispositivos que no coinciden en una salpicadura siguen coincidiendo en el juego. `world.effect(nombre, { at, follow, color, scale, count, loop })` lanza partículas dibujadas en la tarjeta gráfica (un solo draw call por efecto). Los nombres: `splash` (salpicadura), `spark` (chispas), `sparkle` (destellos que se quedan flotando), `smoke` y `dust`. `at` es `[x, y, z]` o una cosa (un barco que salpica); `follow` hace que el efecto siga a una cosa mientras vive; `color` es un número (`0xffaa00`) o `'#ffaa00'`; `scale` agranda; `count` cambia cuántas (hasta 200; la calidad deja un 100 %, 70 %, 50 % o 30 %); `loop: true` no acaba hasta `stop()`. Devuelve `{ stop(), done, count, object }`; acaba solo, y `world.effects` lista los vivos. Un nombre que no existe, sin sitio, o con `scale`/`count` fuera de rango falla con `world_bad_effect` diciendo qué escribir. `world.sound(nombre | dirección | hoja.json, { at, follow, loop, loopStart, loopEnd, volume, rolloff })` suena **desde su sitio** (Web Audio con un panner; el oyente sigue a la cámara). Sin `at` ni `follow` suena sin posición (música, menús). Los sonidos de Neblla, generados por Neblla —sin ficheros ni licencias que citar— son `splash`, `hit`, `slash`, `coin`, `step`, `wave` y `gull`; con una dirección (`/media/olas.mp3`, una URL) suena cualquier sonido de tu app. Arranca tras el primer toque o tecla de la página (el navegador no deja antes); lo que se pide antes suena en cuanto se puede. Con la **hoja `.json` que escribe Eco** (la de una canción o una música de fondo) basta una dirección: Mundo escoge la fuente más ligera que el aparato sabe tocar (Opus antes que MP3) y, con `loop: true`, repite **entre los puntos de esa fuente** sin salto (`AudioBufferSourceNode`, no `