Capítulo identidad de la guía Neblla — login SSO, scopes, guests, db.save() y persistencia. El contrato base está en core.txt; el criterio de cuándo usar Neblla, en empezar.txt. # Identidad y persistencia ## El idioma del usuario no está en el SDK El backoffice lo sabe (`neblla_list_users` devuelve un `language`), pero no hay nada en `neblla.auth.me` ni en ninguna parte del SDK que lo exponga. Si la app tiene que localizarse, lee `navigator.language` en tu propio código — no hay señal de Neblla para esto, y los idiomas de la ficha de la galería son otra cosa que le pasas a `neblla_publish_app`. ## Los ids: el de la persona, el del usuario y el de la sesión Una persona tiene **una sola cuenta Neblla**; su identidad es el **personId**, del que cuelgan las amistades entre apps. Tu app nunca lo ve. Lo que tu app ve son dos ids: - **El del usuario en tu app** (`appUserId`): su fila en tu app, el `_id` del backoffice y de `neblla_list_users`, el `sub` del ID token y el que llega en `neblla.auth.on('login')`. Es el mismo en todos sus dispositivos y distinto en cualquier otra app. Solo existe con sesión: un invitado no tiene fila. - **El de la sesión** (`neblla.auth.me.id`, las claves de `neblla.room.users`, el destino de `neblla.message.to`): uno por dispositivo y conexión, también de invitado. Es el que usan las salas y el SDK en directo, pero no reconoce a la misma persona otro día ni en otro dispositivo. Para guardar algo de una persona, usa el id del usuario; para hablar con alguien de la sala, el de la sesión. Cuando un capítulo dice "ids por app, no personIds", es esto: no puedes dirigirte a una persona entre apps, solo a un usuario dentro de la tuya. ## El muñeco de la persona y el cuerpo de tu juego La cuenta Neblla de cada persona guarda **un** muñeco (piel, pelo, cara, colores y lo que lleva de la tienda), que tu juego lee con `neblla.avatar.get()`. Su **cuerpo es siempre el estándar**: la altura y los cuerpos distintos no son de la persona, son de cada juego. Tu juego elige entre tres caminos: (a) sus propios avatares compatibles con Neblla, dejando o no que el jugador lleve sus cosas (`world.avatarRules({ body, shape, model, carry })`); (b) un modelo propio, compatible con el mundo pero no con los jugadores, sin sus cosas; (c) su propio motor, usando solo la identidad. Está todo en mundo.txt, «Cuerpos en tu juego». El sello «compatible con Mundo» solo se pide si el jugador ve su propio muñeco de Neblla. ## Invitados **Si hay sesión lo dice `neblla.auth.me.loggedIn`.** `neblla.auth.me` eres tú en la app, también de invitado: `{ id, publicInfo, grantedScopes, loggedIn }`. Tu papel en la sala (`{ id, publicInfo, isPresenter, isMaster, isBoard, isMe, purchases }`) está en `neblla.room.me`. Para enterarte de los cambios, escucha `neblla.auth.on('login')`, `('logout')` y `('expired')`, y parte de "no identificado". Y protege `neblla.db.data`: léelo como `neblla.db.data || {}`, porque un visitante nuevo no tiene nada guardado y los ejemplos que escriben `info.name` dan por hecho que es un objeto. **Lo que necesita login, en tres niveles:** 1. **Sin login (invitado):** usar la app. Salas, chat, multijugador completo, ser master o board, leer la comunidad, la IA que paga el propio usuario (`userTokenMode: 'browser'`) y guardar progreso en su dispositivo. 2. **Con login, de Neblla o el tuyo:** todo lo que necesita saber quién es la persona en tu app. Comprar (salvo con la cuenta ligera, abajo), guardar en el servidor (el progreso en todos sus dispositivos), amigos, mensajes con el dev, publicar, valorar y reseñar en la comunidad, salas `private` o permanentes y ser su dueño, la IA que pagas tú (lleva un límite por usuario), y los baneos y flags que duran. Sin login responden `login_required`. 3. **Solo con cuenta Neblla:** lo que depende de la persona en toda la red de Neblla, no solo en tu app. Reseñar, valorar y guardar favoritos en la galería, y borrar su cuenta de Neblla. **Cuenta ligera: comprar sin registrarse (encendida por defecto).** Un invitado compra dejando solo su email en el pago de Stripe. Nace una cuenta ligera, sin contraseña y ligada a ese dispositivo, y la compra funciona en el acto. En otro dispositivo, `neblla.shop.restore(email)` le manda un enlace (responde `{ sent: true }` exista o no ese email): al pulsarlo se confirma el email y ese dispositivo se une. Si un día hace login con ese mismo email, las compras pasan a su cuenta. Para todo lo demás del nivel 2 sigue haciendo falta login. Si prefieres que solo compre quien ha hecho login, apágala con `neblla_update_app({ purchases: { emailCheckout: false } })` (o Config → Tienda): entonces `neblla.shop.buy()` de un invitado le pide hacer login en la misma ventana del pago y sigue con la compra en cuanto entra. Para que una sala sea solo de usuarios con login, `requireLogin` (ver salas.txt). ## Tu propio login Si empiezas de cero, el login de Neblla te lo da todo hecho, sin una línea de servidor: Google, email y contraseña, recuperar la contraseña y la pantalla de permisos. Esto es para cuando la app ya tiene sus usuarios. Si la app ya tiene su sistema de usuarios, no hace falta que nadie se cree una cuenta de Neblla: tu servidor le dice a Neblla quién es cada uno. 1. **El usuario entra con tu login de siempre.** 2. **Tu servidor pide un token a Neblla** con la clave secreta de login de la app (se crea y se rota con `neblla_auth_secret` o en el panel, Ajustes › Login propio, y vive solo en tu servidor): ``` POST https://neblla.com/api/apps//auth/token Authorization: Bearer { "externalId": "user-123", "email": "ana@ejemplo.com", "emailVerified": true, "name": "Ana" } ``` 3. **Neblla devuelve un token de un solo uso** que caduca en un minuto. 4. **Tu front lo pasa al SDK:** `await neblla.auth.login({ token })`. Desde ahí es un usuario con login en tu app (nivel 2 de arriba), identificado por tu `externalId`, el mismo en todos sus dispositivos. Tiene amigos dentro de tu app; lo que depende de la red de Neblla (nivel 3), no. Si esa persona también entra un día con su cuenta Neblla y `emailVerified` es `true`, las dos cuentas se unen en tu app, y solo en tu app. **Cambio de cuenta.** Si en ese navegador ya había otra sesión, el SDK no las mezcla nunca: hace logout de la actual, avisa del cambio, recarga la página y hace login con la nueva ya en limpio. `neblla.auth.on('switch')` te deja poner tu propio aviso. Si el token caduca mientras tanto, llega `neblla.auth.on('tokenExpired')`: pide otro a tu servidor. Quien tiene la clave secreta puede hacer login como cualquier usuario de tu app: trátala como la de Stripe. **Sin servidor, con tu proveedor de login.** Si usas Auth0, Clerk, Supabase Auth, Firebase, Cognito o cualquier proveedor que emita un JWT, dile a Neblla quién es (`neblla_update_app({ auth: { issuer, jwksUrl?, audience? } })`, o una lista de hasta 5 proveedores) y pásale al SDK el token que ya tienes: `await neblla.auth.login({ jwt })`. Neblla lo comprueba con las claves públicas de tu proveedor (RS256 o ES256; sin `jwksUrl`, las busca por OpenID discovery), y el `sub` del token es tu `externalId`. Un `sub` del proveedor nunca se queda con un usuario creado por los tokens de tu servidor (`identity_conflict`). Si en el navegador había otra sesión, el cambio de cuenta es el mismo de arriba. No hace falta servidor tuyo. Es la filosofía de siempre: tráete lo tuyo (BYOAuth), igual que tu base de datos. Sin login, el usuario entra como **invitado**, con un id y un sitio en el lobby. Lo que guarda con `db.save()` va a `localStorage`, en ese dispositivo; para tenerlo en todos sus dispositivos, que haga login. **Al hacer login, su progreso se viene con él.** Si la cuenta aún no tiene nada guardado, el SDK sube a la cuenta lo que el invitado tenía y borra la copia local: quien usa la app como invitado y luego crea su cuenta no pierde nada, y desde ahí se sincroniza entre sus dispositivos. Si la cuenta ya tiene datos (porque ya la usó en otro dispositivo), mandan los de la cuenta mientras tenga la sesión abierta, y la copia de invitado de este dispositivo se guarda intacta: al hacer logout, el dispositivo vuelve a ella. No hay fusión. `neblla.auth.on('login')` trae en `data` el resultado. ## SSO en dos capas 1. El usuario tiene **una sola cuenta Neblla** para todas las apps Neblla, que se abre **con Google o con email y contraseña** (el email se confirma al registrarse; olvidar y cambiar la contraseña están resueltos). Cada app recibe un ID estable para él — pero distinto en cada app: ninguna app puede correlacionarlo con otra a través de esos IDs. Lo que sí es común a todas las apps es su **perfil público**: en Comunidad cada entry lleva `owner: { id, name, slug }`, donde `id` es el userId por app (el mismo que devuelven `neblla_list_users` y la búsqueda por email) y `name` y `slug` son el nombre público del usuario, el mismo en cualquier app Neblla. 2. Para cualquier dato más allá del ID, la app pide autorización vía *scopes*. La primera vez, el usuario ve una pantalla de consentimiento con la lista exacta. Mientras no cambien los scopes, los logins siguientes son silenciosos. Si se añade un scope nuevo, el popup vuelve a salir. ```javascript neblla.auth.login({ scopes: ['email', 'friends'] }); ``` Para intentarlo al cargar la página sin molestar: `await neblla.auth.login({ silent: true })` solo hace login si el usuario ya tiene una sesión de Neblla que cubre los scopes pedidos, nunca abre el popup y devuelve `true` o `false`. Con `false`, deja el botón de Login para cuando el usuario quiera. Scopes disponibles: - **`identity`** — id estable por app. No expone datos de la cuenta Neblla. Siempre concedido. - **`email`** — añade `email` al payload de `auth.on('login')`. - **`friends`** — desbloquea `neblla.social.friends`, `neblla.social.requests` y los métodos de gestión de amistades. - **`discoverable`** — permite que el usuario aparezca en las listas de amigos de otros usuarios *de esta app concreta*. Cada app necesita su propia concesión. Evento `neblla.auth.on('login')` **(CLIENT)** — `{ type, data, grantedScopes, appUserId, email? }` (`appUserId`: el id del usuario en tu app, el mismo en todos sus dispositivos). `neblla.auth.logout()` hace logout del usuario en tu app pero **no** hace logout de su sesión Neblla — sigue con login para otras apps. Para hacer logout de su sesión Neblla, el usuario usa "Switch" en el popup SSO: todas las apps que tenga abiertas en ese navegador vuelven a modo invitado a la vez (y siguen así al recargar), y tu app recibe `neblla.auth.on('logout')`; sus otros dispositivos no se tocan. Evento `auth.on('logout')` — `{}`. Eventos de sesión: `neblla.auth.on('expired')` **(CLIENT)** `{}`. Tras reconectar no hay un evento aparte: vuelve `neblla.room.on('joined')` con `isReconnect: true`. ## Decirle a tu propio servidor quién es el usuario Si tienes un servidor tuyo, no te fíes de un id que te mande el navegador: pide un token firmado por Neblla y verifícalo en tu servidor. ```javascript const { token, expiresIn } = await neblla.auth.idToken(); // JWT firmado (EdDSA), dura 10 minutos await fetch('https://tu-servidor.example/partida', { method: 'POST', headers: { Authorization: 'Bearer ' + token } }); ``` El token lleva `sub` (quién es el usuario en tu app: con login, su id en la app, el `_id` del backoffice y de `neblla_list_users` y el `appUserId` de `auth.on('login')`, el mismo en todos sus dispositivos; de invitado no tiene, así que es el id de esta sesión), `session` (el id de esta sesión, el que ven las salas y el SDK), `aud` (el id público de tu app), `iss`, `iat`, `exp`, `logged_in` (si entró con su cuenta Neblla) y `email` solo si concedió el scope `email`. Nunca el id de su cuenta entre apps. Para verificarlo: - Con cualquier librería JWT, contra las claves públicas de `https://neblla.com/.well-known/jwks.json` (comprueba también que `aud` sea tu app). - Sin librería: `POST https://neblla.com/api/auth/verify-id-token` con `{ token, appId }` → `200 { valid: true, claims }` o `401 { valid: false, error }` (`wrong_audience` si el token es de otra app, `expired`, `bad_signature`…). ## db.save() y db.data ```javascript neblla.db.save({ record: 1250, settings: { sonido: false } }); ``` - **Sin login** — se guarda en `localStorage` del dispositivo actual. Local. - **Con login** — se guarda en la base de datos de Neblla y se sincroniza entre todos los dispositivos del usuario. Antes espera un poco en su navegador y sale en los momentos clave (abajo). La misma línea de código en ambos casos; Neblla decide el camino según el estado de sesión. En cada reconexión, `neblla.db.data` está cargado y listo — no hay que pedirlo. **Por defecto, `db.save()` solo toca lo que mandas** (`mode: 'over'`): esos campos se sobrescriben, a cualquier profundidad, y el resto del documento se queda como estaba. **Y se cobra solo lo que mandas,** no el documento entero. ```javascript neblla.db.save({ record: 1250 }); // over: cambia record, lo demás intacto neblla.db.save({ ajustes: { sonido: true } }, { mode: 'under' }); // solo rellena lo que falta (valores por defecto) neblla.db.save({ nivel: 1, monedas: 0 }, { mode: 'full' }); // reemplaza el documento entero neblla.db.save({ trucos: true, borrador: true }, { mode: 'delete' }); // borra esos campos ``` - **`over`** (por defecto): lo que mandas pisa lo que había. - **`under`**: solo escribe donde no hay nada. Para poner valores por defecto sin pisar los del usuario. - **`full`**: reemplaza el documento entero. - **`delete`**: borra los campos que nombras. **Qué cuenta como vacío,** para `under` (se rellena) y para `over` (no pisa): `empty: ['null', 'emptyArray', 'emptyString']`, solo los que pongas. Por defecto, solo un campo que no existe está vacío. **Arrays.** Por defecto un array se reemplaza entero. Con `arrays` decides cómo se combina, para todos o campo a campo: ```javascript neblla.db.save({ inventario: ['espada'] }, { arrays: 'push' }); // añade al final neblla.db.save({ inventario: ['poción'] }, { arrays: 'pull' }); // quita esos elementos neblla.db.save({ logros: ['primer-nivel'] }, { arrays: { logros: { method: 'complete', unique: true, sort: 'asc' } }, // añade solo los que no estén, sin repetidos, y ordena }); ``` `method`: `'replace'` (por defecto), `'push'`, `'pull'` o `'complete'`. Después, si quieres: `unique` (sin repetidos) y `sort: 'asc' | 'desc'`. **Varios dispositivos a la vez:** con `crdt: true`, en `over` gana el valor más nuevo campo a campo aunque las escrituras lleguen desordenadas (escribir `null` borra el campo). La hora de cada escritura se corrige con el reloj del servidor y no puede ir más de 60 s por delante, así que adelantar el reloj del dispositivo no da ventaja; una hora pasada solo puede perder. **Con login, lo que guardas espera en el navegador del usuario y sale en los momentos clave.** `db.save()` cambia `neblla.db.data` al instante y deja el guardado en ese dispositivo; el SDK lo manda al servidor, junto con los demás, al cerrar o salir de la página, al cambiar de pestaña, al salir de una sala (el fin de la partida), cuando el mismo usuario entra desde otra pestaña o dispositivo (el servidor le pide antes lo que tenga), cuando lo marcas como inmediato (`{ now: true }`) y como mucho cada 10 minutos. Si la respuesta no llega (el servidor reiniciándose, sin red), lo reintenta: no se pierde ni se aplica dos veces. Puedes llamar a `db.save()` tantas veces como quieras: los guardados seguidos en `over` se juntan en uno (con `crdt`, `empty` o `arrays` van uno a uno, en orden). `neblla.db.waiting` dice cuántos esperan en este dispositivo. **Al hacer login desde otra pestaña o dispositivo**, el servidor pide a las sesiones abiertas de esa persona lo que tienen y espera **como mucho 3 segundos** antes de cargar su documento. Si la otra pestaña tarda más (sin red, un móvil dormido), la nueva carga el documento sin eso; lo que llega después se guarda igual, pero esa pestaña no lo ve hasta que vuelve a cargar (`db.load`, o recargando la página). **Al hacer logout**, `logout()` manda antes lo que espera; si no puede (sin red, sin cuota), se queda en ese navegador bajo ese usuario y sale la próxima vez que **ese mismo usuario** entre en él: otro usuario del mismo navegador nunca lo ve ni lo manda. Si el usuario fue borrado entretanto, el servidor rechaza lo que quedaba (`account_gone`) y el SDK lo descarta: lo borrado no vuelve. **Una promesa.** `db.save()` se cumple al momento con `{ data, mode, pending, onDevice: true }`. Con `{ now: true }` se manda ya y se cumple cuando el servidor lo guardó (`onDevice: false`; `pending: true` si la base de datos está llena y espera en la cola), o falla con el código del error (`save_too_large` con `bytes` y `max`, `invalid_save`, `invalid_at`, `login_required`, `quota_exceeded`); si el error trae `onDevice: true`, el guardado sigue en el dispositivo y saldrá después. Úsalo para lo que no puede esperar, como lo que da una compra. Un guardado que el servidor no aceptará nunca (un documento demasiado grande, una fila que no es suya) llega por `db.on('error')`, con `save` diciendo cuál, y se descarta. Sin login se cumple al momento, en `localStorage`. No hace falta esperarla: un fallo que nadie espera no sale como error sin capturar. ```javascript neblla.db.save({ nivel: 4 }); // espera en el navegador y sale en el próximo momento clave try { const { pending } = await neblla.db.save({ gemas: 500 }, { now: true }); // tras una compra: ya if (pending) aviso('Guardado en espera: la base de datos de la app está llena'); } catch (e) { if (e.onDevice) aviso('Se guardará en cuanto haya conexión'); else if (e.code === 'save_too_large') aviso('Tu partida es demasiado grande'); } ``` **Sin cuota, los guardados esperan.** Si la app agotó su cuota del día (o el usuario su parte, `playerQuota`), el servidor no acepta guardados: se quedan en el navegador y salen cuando vuelve la cuota (a las 00:00 UTC o al entrar un pack), en la siguiente visita si hace falta. Si el usuario no vuelve, se pierden; el panel (Configuración → Capacidad, y `savesWaiting` en `neblla_get_app`) cuenta cuántos usuarios tienen guardados esperando. Mientras tanto, entrar desde otra pestaña o dispositivo puede mostrar datos de antes: es lo que cuesta no tener cuota. **Freno contra bucles.** Con más de 1000 guardados esperando que no se pueden juntar (por ejemplo, un `arrays: 'push'` en cada frame), el dispositivo rechaza los nuevos con `save_loop` (en `db.on('error')`) y el panel lo avisa. Busca el `save()` que se repite sin fin. **Lo que no es del usuario espera en la memoria de Neblla.** Los datos de una sala permanente (`room.setData`), las filas que tu servidor escribe por un usuario (`neblla.db.save(datos, { table, user })` desde Server) y el `publicInfo` de un usuario con login no esperan en ningún navegador: Neblla los tiene en memoria, todos los ven al instante y se leen de ahí, y van a la base de datos en los momentos clave: la sala se vacía o se cierra, tu servidor sale de la sala, el usuario sale de la app o hace logout, y como mucho cada 3 minutos; al reiniciarse Neblla (cada despliegue) se escribe todo antes de parar. Para lo que no puede esperar (el final de una partida que reparte premios), `{ now: true }` lo escribe ya: `room.setData(datos, { now: true })`, `neblla.db.save(datos, { table, user, now: true })`. Si Neblla se cae sin avisar, se pierde como mucho lo de esos últimos minutos. Si tu base de datos rechaza una fila que esperaba (una restricción tuya, una columna que cambiaste), Neblla lo intenta en 5 momentos y luego la descarta (con tu base caída, en cambio, espera lo que haga falta): déjale a la tabla las columnas que Neblla creó. **Sin conexión, y `at`, la hora del cambio.** Sin red, lo que guardas ya espera en el dispositivo y sale al volver. Si el usuario juega en varios dispositivos, `crdt: true` hace que campo a campo gane el cambio más reciente, no el último en llegar: así un móvil que estuvo tres días sin red no pisa lo que el usuario hizo ayer en el portátil, y sí deja lo que nadie tocó después. La hora de cada guardado es la de cuando lo hiciste; si el cambio pasó antes (lo apuntaste tú), dásela con `at`: un `Date` o milisegundos del reloj del dispositivo (el SDK lo corrige con el del servidor), que activa `crdt` y solo vale con `mode: 'over'` (para borrar un campo, manda `null`). ```javascript neblla.db.save({ monedas: 120 }, { crdt: true }); // gana si es lo más nuevo neblla.db.save({ record: 900 }, { at: cambio.hora }); // un cambio que apuntaste antes ``` Cuando un lote llega al servidor, `neblla.db.data` se actualiza con el documento resultante (con lo que aún espera encima) y llega `neblla.db.on('merged')` (`{ data, mode, pending }`). Si la base de datos de la app está llena, el guardado no se pierde: espera en la cola del servidor, `pending` llega a `true` (también `neblla.db.pending`) y se escribe cuando haya sitio (ver publicar.txt). Patrón: recordar nombre entre sesiones (client.js): ```javascript let info = neblla.db.data; if (!info.name) { info.name = prompt('Tu nombre?') || 'Guest'; neblla.db.save(info); } neblla.db.setPublicInfo({ name: info.name }); ``` ## Tablas: tus propios datos, con columnas e índices Además de su documento de `db.save()`, cada usuario puede tener **filas en tablas que defines tú**: sus reservas, sus partidas, sus pedidos. ```javascript await neblla.db.save({ clase: 'yoga-martes', fecha: '2027-04-12', estado: 'confirmada' }, { table: 'reservas' }); const mias = await neblla.db.load({ table: 'reservas', where: { fecha: { gte: '2027-04-12' } }, orderBy: '-fecha', limit: 20 }); await neblla.db.save({ ...mias[0], estado: 'cancelada' }, { table: 'reservas', id: mias[0].id }); // reemplaza una fila tuya await neblla.db.delete({ table: 'reservas', id: mias[0].id }); ``` - **También esperan en el navegador**, como el documento: una fila nueva recibe su `id` al momento (`db.save` la devuelve con él), y `db.load` manda antes lo que espera, así lees lo tuyo. Borrar una fila que no existe o que no es del usuario da `row_not_found`. Con `{ now: true }` sale ya y se cumple con la fila guardada, o falla con su error (`too_many_rows`, `invalid_field_value`…); sin él, esos errores llegan por `db.on('error')`. - **Cada fila va ligada a su usuario.** Desde el cliente, un usuario solo escribe y lee sus propias filas; las de otro, solo si marcas la tabla como visible (abajo, «Lo que ven otros usuarios»). Hace falta login (un invitado recibe `login_required`). - **Filtros y orden:** `where` es `{ campo: valor }` o `{ campo: { eq, ne, gt, gte, lt, lte, in } }`, solo sobre campos declarados; `orderBy` es `'campo'` o `'-campo'`, también `createdAt`. - **Límites:** filas de hasta 8 KB y un tope de filas por usuario en cada tabla (`maxRowsPerPlayer`, 500 por defecto; se cambia en la declaración). **Y según lo que haya comprado:** `neblla_update_app({ limits: { premium: { maxBytesPerUser: 1000000, tables: { notas: { maxRowsPerPlayer: 5000 } } } } })` da más a quien tiene `premium`; sin compra, los de la app. - **En directo, si lo necesitas:** `const stop = neblla.db.watch({ table: 'tareas', group: 'acme' }, ({ action, row }) => …)` avisa de cada fila nueva, cambiada (`action: 'saved'`) o borrada (`'removed'`) que ese usuario puede leer. `stop()` lo corta. Cada aviso cuesta como un mensaje. - **Un enlace de solo lectura, sin cuenta:** `const { url, id, until } = await neblla.db.shareLink({ table: 'tareas', group: 'acme', where: { proyecto: 'web' } })`, con `expires` (una fecha, la que quieras; 30 días por defecto, o `false` para que no caduque nunca; `until` es entonces `null`). Quien lo abre, sin login, ve esas filas y nada más: el enlace lleva `?nbshare=…` y en tu app `neblla.db.pendingLink` dice si has llegado por uno (es el `id` del enlace, o `null`) y `await neblla.db.openLink()` devuelve `{ table, until, rows }`, solo para leer y sin dueños. `neblla.db.links()` lista los tuyos y `neblla.db.revokeLink(id)` lo corta (solo quien lo creó). Para crearlo hace falta `read` en esa tabla. Ejemplo: el cliente de una agencia sigue el avance de su proyecto sin registrarse. - **Declaras la tabla una vez,** con campos con tipo e índices: `neblla_update_app({ tables: { reservas: { fields: { clase: 'string', fecha: 'date', estado: 'string' }, index: ['clase', 'fecha'] } } })`. - **Con tu base de datos (BYODB), son columnas de verdad.** Neblla guarda el JSON y además, en Postgres o MySQL, la tabla `neblla_` lleva una columna por campo declarado, con sus índices; en Mongo, la colección `neblla_` con índices. Puedes consultarlas con tu propio SQL desde fuera. - **Consultar entre usuarios** —cuántas reservas tiene la clase del martes, tu posición en un ranking, el informe del mes— se hace desde el servidor: `neblla.db.load({ table: 'reservas', where, orderBy, limit, count })` en tu `room.js` o en Server. Desde Server (propio o en la nube), la API de administración, el MCP (`neblla_table_query`) y los paneles se leen filas de cualquier usuario. **En Fog solo conteos** (`{ count: true }`), porque corre en el dispositivo de un usuario: vale para "cuántas reservas tiene la clase" o "tu posición"; si pide filas, responde `count_only_on_fog`. Desde el cliente, nunca: ahí `db.load` solo ve las filas propias. **Grupos y permisos: equipos, clientes y tu servidor.** Como en las herramientas de empresa: primero defines **papeles** con sus derechos sobre cada tabla, después creas **grupos** (un equipo, un cliente) y añades personas con su papel. ```javascript neblla_update_app({ roles: { equipo: { tareas: 'read write' }, // los empleados leen y escriben cliente: { tareas: 'read' }, // el cliente solo ve } }) // un grupo por agencia, con su gente neblla_group({ appId, action: 'create', name: 'acme' }) neblla_group({ appId, action: 'add', group: 'acme', user, role: 'equipo' }) // user: su id en la app, el de neblla_list_users ``` - **Papeles:** derechos `read` y/o `write` sobre cada tabla, hasta 20 papeles. Errores: `invalid_roles`, `invalid_role_name`, `invalid_rights`. - **Grupos:** `create`, `delete` (los miembros salen; sus filas se quedan, pero nadie llega a ellas desde el cliente), `add` (repetirlo cambia el papel), `remove`, `list` (los grupos; con `{ group }`, sus miembros `[{ user, role }]`) y `of { user }` (los grupos de alguien). Errores: `group_exists`, `group_not_found`, `unknown_role`, `user_not_found`, `member_not_found`. - **El grupo `moderators` es reservado:** sus miembros son los moderadores de la app (comunidad, salas; ver comunidad.txt). Neblla lo crea con el primero; no se crea ni se borra a mano (`reserved_group`), y nadie lo deja desde el cliente. Su papel es siempre `moderator`, que no es uno de tus papeles: `add` va sin papel (o con `moderator`; otro da `reserved_group`), y ser moderador no da derechos sobre ninguna tabla aunque declares un papel con ese nombre. Si un moderador necesita filas de un grupo, ponlo además en ese grupo con su papel. Como todo grupo, vive en los datos: pre con los datos de pro usa el de pro; pre con los suyos, el suyo. Un moderador lo ve en `neblla.groups.mine()` (`{ group: 'moderators', role: 'moderator' }`) y, como miembro, puede listar a los demás moderadores con `groups.members('moderators')`; los demás jugadores no. Nombrar o quitar uno, por cualquier camino, se apunta en el registro de moderación si la app lo lleva. - **Desde dónde:** el MCP, la API de administración (`POST /api/apps//groups/` con la clave de servidor y `{ group, user, role }`) y Server, con `neblla.groups.create(name)`, `.delete(name)`, `.add(group, user, role)`, `.remove(group, user)` y `.list(group?)`; ahí `user` puede ser el id de sala de un usuario conectado (con login) o su id en la app. Fog recibe `not_allowed`, y el cliente también, salvo lo que le permitas: - **Tus usuarios crean sus grupos, si lo permites.** Con `neblla_update_app({ groups: { clientCreate: true, creatorRole: 'equipo', maxMembers: 15, invitable: ['equipo', 'cliente'] } })`, desde tu app y con login: ```javascript await neblla.groups.create('acme'); // quien lo crea entra como 'equipo' const { url, code, until } = await neblla.groups.invite('acme', { role: 'cliente' }); // caduca a los 7 días await neblla.groups.join(neblla.groups.pendingInvite); // quien abre el enlace (?nbgroup=…) const gente = await neblla.groups.members('acme'); await neblla.groups.leave('acme'); ``` Invita quien tiene un papel con `write` en alguna tabla del grupo, y nadie da un papel que no esté en `invitable`. Errores: `role_not_invitable`, `group_full`, `invite_not_found`. Así una agencia se da de alta, monta su equipo e invita a su cliente sin que intervengas. - **Una fila puede ser de un usuario o de un grupo:** ```javascript await neblla.db.save(datos, { table: 'tareas', group: 'acme', id }); const tareas = await neblla.db.load({ table: 'tareas', group: 'acme', where: { estado: 'abierta' } }); await neblla.db.delete({ table: 'tareas', group: 'acme', id }); ``` Leer pide `read` y escribir o borrar pide `write` según el papel: `not_in_group` si no es miembro, `not_allowed` si su papel no llega. La fila lleva `owner: 'g:acme'` y no sale entre las filas propias del usuario. Nadie de fuera la ve. - **Cada usuario ve sus grupos:** `await neblla.groups.mine()` → `[{ group, role }]`. Lo que ve un empleado no es lo que ve un cliente: son papeles distintos dentro del mismo grupo. - **Un solo SDK para todos los papeles.** El cliente carga el mismo `neblla.js` que el equipo; lo que su papel no deja, el servidor lo responde con `not_allowed`, así que no hace falta (ni sirve) esconder funciones. Para saber qué le funciona a cada papel, `neblla_get_llms({ section: 'access', appId })` lo lista, hecho de las mismas tablas que aplica el servidor (ver mcp.txt). - **Se guardan con los datos de tu app** (con BYODB, en tu base de datos). - **Tablas que solo escribe Neblla:** `{ write: 'neblla' }` en la declaración (por defecto `'player'`). El usuario sigue leyendo sus filas, pero `db.save` y `db.delete` desde el cliente (una a una, en lote o desde la cola) responden `table_read_only`, y el SDK ya lo sabe al conectar. Así nadie se escribe «tengo la carta legendaria» o 9.999 monedas en su colección. Las escriben un sorteo (abajo), un intercambio, tu servidor de verdad (Server, propio o en la nube, sin necesidad de `server: 'write'`), la API de administración, el MCP y los paneles; Fog no, porque corre en el dispositivo de un usuario. - **Tu servidor puede escribir por un usuario** (por ejemplo, la puntuación ya verificada) si la tabla se lo permite: `{ server: 'write' }` en su declaración. Desde Server (propio o en la nube), `neblla.db.save(datos, { table: 'puntos', user, id? })` escribe por un usuario de la sala (`user` es su id en la sala; el usuario necesita login) y `neblla.db.delete({ table: 'puntos', id })` borra. Lo que escribe tu servidor se lee al momento y va a la base de datos en los momentos clave (arriba, «Lo que no es del usuario»); `now: true` lo escribe ya. La API de administración también exige la marca. Sin ella, `server_write_not_allowed`. Para un usuario cuya cuenta se acaba de borrar, `owner_gone` (durante una hora; lo que esperaba por él no se escribe); también desde el MCP y los paneles. Fog no puede, porque corre en el dispositivo de un usuario: recibe `not_allowed`. El MCP y los paneles escriben siempre, porque eres tú (salvo `owner_gone`). ## Sorteos justos: sobres de cartas, cofres, premios (`neblla.db.draw`) Un sobre de cromos, un cofre, una ruleta de premios: el usuario paga (créditos, o una apertura gratis cada tanto) y recibe cartas al azar. Lo justo lo pone Neblla: las reglas están en la configuración de tu app, no en el código del cliente, y cada apertura baraja el mazo entero de nuevo y escribe las cartas en la colección del usuario **antes** de enseñarlas. ```javascript neblla_update_app({ tables: { coleccion: { fields: { criatura: 'string', rareza: 'string', fuerza: 'integer' }, write: 'neblla' } }, draws: { sobre: { into: 'coleccion', // una tabla que solo escribe Neblla cards: [ { copies: 60, data: { criatura: 'brote', rareza: 'comun', fuerza: 1 } }, { copies: 1, data: { criatura: 'dragon', rareza: 'legendaria', fuerza: 9 } } ], perOpen: 3, // cartas por apertura (sin repetir copia) cost: [ { credits: 'gemas', n: 5 } ], // uno o varios precios, en tipos de crédito free: { everyHours: 24, max: 2 }, // una gratis al día, se acumulan hasta 2 } }, }) // client.js const { cards, paid, free } = await neblla.db.draw('sobre'); // gratis si hay, si no el primer precio que cubre su saldo await neblla.db.draw('sobre', { pay: 'gemas' }); // o elige: 'free' o un tipo de crédito const { cards: todas, cost, free: gratis, last } = await neblla.db.drawInfo('sobre'); // cada carta con su chance ``` - **La probabilidad es pública:** `drawInfo` da cada carta con `copies` y `chance` (copias / total), el precio, las aperturas gratis (`available`, `nextAt`) y la última apertura del usuario. Enséñala: es lo que piden las tiendas de apps para las cajas de botín. - **Pagar y recibir van juntos:** si no puede pagar, no se saca nada (`not_enough_credits` con `cost` y `nextFreeAt`; `no_free_open` con `nextAt`; `invalid_pay`). Si no le caben (`maxRowsPerPlayer`), `too_many_rows` antes de cobrar. Una apertura cobrada que se cortó a medias se termina en la siguiente llamada del usuario. - **Las cartas son filas normales** de la tabla, del usuario, con su `id`: las lee con `db.load`, se intercambian (Intercambios, abajo) y se llevan a una partida (servidor.txt, «Una baraja de cada jugador»). - **Reglas:** hasta 20 sorteos, 500 cartas distintas, 100.000 copias, 50 por apertura, 5 precios y 100 aperturas gratis acumuladas. Cada carta tiene que caber en los campos de `into`. Errores: `invalid_draws` (con `field`, `draw`, `reason`), `invalid_draw_name`, `draw_table_not_read_only`, `unknown_draw`. Se publican con el resto de la configuración (pre → pro). - **Tú lo revisas:** el MCP (`neblla_draws`: `info` y `opens`, el registro de aperturas por usuario) y el panel, en Funciones › Datos › Sobres y sorteos. Cada apertura cuesta como un mensaje. ## Puntuaciones de un juego para uno (`neblla.scores`) Un juego para uno (un Wordle, un juego de reflejos, un test) no tiene sala ni árbitro: su único servidor es el dispositivo del usuario, que puede mandar «lo acerté en 1» sin jugar. `neblla.scores` pone a Neblla en medio: la partida empieza en Neblla, la puntuación se manda contra esa partida una sola vez, y Neblla la comprueba antes de que entre en el ranking. Lo que no pasa no entra. Declaras cada ranking una vez, con `neblla_update_app({ scores })` o en el panel (Funciones › Puntuaciones y clasificaciones: crear, renombrar y borrar rankings, quién gana y sus límites), y "Pasar a pro". Renombrar un ranking se lleva sus puntuaciones al nombre nuevo, y borrarlo las borra (el panel lo avisa antes): las del borrador al momento, las de la app publicada cuando "Pasar a pro" lleva ese cambio (si el borrador usa los datos de la publicada, todas al pasar a pro); tirar los cambios del borrador (`neblla_promote` `reset`) devuelve sus puntuaciones renombradas a su nombre. En el MCP el renombre se dice: `neblla_update_app({ scores: { palabra: { … } }, scoreRenames: { wordle: 'palabra' } })`; sin `scoreRenames`, un nombre nuevo es un ranking vacío y el que falta se borra con sus puntuaciones. Si luego vuelves a una versión de antes del cambio de nombre («Volver a esta», `neblla_promote` con `action: 'rollback'`), las puntuaciones vuelven al nombre de esa versión, y el siguiente «Pasar a pro» las lleva otra vez al nombre nuevo. ```javascript // neblla_update_app({ scores: { // wordle: { order: 'asc', min: 2, max: 6, integer: true, minSeconds: 10, seed: 'daily', runsPerDay: 1 }, // reflejos: { order: 'asc', min: 120 }, // milisegundos: por debajo de 120 no lo hace nadie // puntos: { maxPerSecond: 50, check: { secret: 'MI_API', path: '/puntuacion' } }, // } }) const partida = await neblla.scores.start('wordle'); // → { run, seed, startedAt, until } // … el juego sale de partida.seed (con 'daily', la misma para todos hoy: la palabra del día) const r = await neblla.scores.submit(partida, intentos, { data: { palabras } }); if (r.ok) mostrar(r.best, r.place); // { ok: true, value, best, newBest, place } else avisar(r.reason); // { ok: false, reason: 'below_min' | … } const top = await neblla.scores.top('wordle', { period: 'day', limit: 10 }); // [{ place, player, value, at, info?, me? }] const mios = await neblla.scores.top('wordle', { period: 'day', friends: true }); // tú y tus amigos, con el puesto entre vosotros const yo = await neblla.scores.mine('wordle'); // { best, bestAt, dayBest, runs, place } o null ``` - **Los límites**, todos opcionales: `order` (`'desc'` por defecto, más es mejor; `'asc'`, menos es mejor: intentos, milisegundos), `min` y `max` del valor, `integer`, `minSeconds` (lo menos que dura una partida de verdad), `maxSeconds` (hasta cuándo se puede mandar una partida; un día por defecto, siete como mucho), `maxPerSecond` (puntos por segundo de partida), `seed` (`'run'`, una por partida; `'daily'`, la misma para todos los usuarios todo el día UTC, que nadie puede saber antes) y `runsPerDay` (partidas por usuario, ranking y día). Hasta 20 rankings por app. - **La partida es de Neblla:** su hora la pone Neblla, así que el tiempo de juego no se falsea desde el cliente. Se manda una vez: el mismo valor otra vez (una respuesta que se perdió) recibe la misma respuesta; otro valor, `run_used`. Empezar otra cierra la que estaba abierta (`run_closed`), para que nadie abra varias y mande la de más suerte. - **Tu propia comprobación** (`check`): Neblla no ejecuta tu código, pero llama al tuyo. Con un secreto de `callApi` (`neblla_set_api_secret`), Neblla hace un POST a su dirección + `path` con `{ event: 'score', appId, board, player, value, data, run: { id, startedAt, seconds, seed, day } }`, y tu código (tu servidor, un Worker, una función en la nube) contesta `{ ok: true }` o `{ ok: false, reason }`. Ahí puedes rehacer la partida con `data` y la `seed`: que la palabra exista, que cada intento cuadre con el anterior. Antes de llamarte, Neblla ya rechazó lo imposible según los límites. Si tu código no contesta, `{ ok: false, reason: 'check_unavailable', retry: true }`: la partida sigue abierta y el mismo envío se puede repetir. `data`, hasta 16 KB. - **Lo rechazado no entra** y queda en el registro de tu app (Construir › Errores y rechazos, «Rechazado»; `neblla_get_logs` con `area: 'score'`). Lo que solo explica una trampa (`below_min`, `over_max`, `too_soon`, `too_fast`, `check_refused`) además marca al usuario, como una sala marcada: suben sus marcas (Personas › Moderación) y tu webhook `flag` recibe `{ room: null, board, reason, users }`. Los demás motivos no marcan: `invalid_value`, `run_expired`, `run_used`, `run_closed`, `run_not_found`, `check_unavailable`. - **Quién:** jugar para el ranking necesita login; el top lo lee cualquiera, también un invitado. Cada partida, envío y lectura cuenta como un mensaje de cuota (`db`). Errores de la llamada (la promesa se rechaza con `code`): `login_required`, `unknown_board`, `runs_per_day` (con `max`), `data_too_large`, `invalid_period`. - **El ranking** es una fila por usuario y ranking con su mejor marca de siempre y la del día (UTC), y su `publicInfo` de ese momento (`info`). Vive en la base de datos de tu app (con BYODB, en tu base de datos: tabla `nb_scores`), se borra con el usuario y solo lo escribe Neblla. Tú lo lees, y quitas una puntuación, con `neblla_scores` en el MCP o en el panel (Funciones › Puntuaciones y clasificaciones, «Mejores puntuaciones»: las mejores de siempre o de hoy, de la publicada o del borrador, y «Quitar»; quien solo mira las ve y no quita ninguna). - **Un nivel entre jugadores (ELO).** `neblla_update_app({ scores: { nivel: { type: 'elo', start: 1200, k: 32 } } })` (también en Funciones › Puntuaciones, «Un nivel entre jugadores») es un ranking que no se juega con `start`/`submit` (`elo_board`): lo mueven los resultados de las partidas de un servicio con `results.board: 'nivel'` (ver core.txt, «Resultados de partidas»). Cada resultado que cuenta sube a los ganadores y baja a los perdedores según el nivel del otro lado (en parejas, la media): a 1200 contra 1200 con `k: 32`, ±16; ganar a alguien más fuerte sube más. `neblla.scores.top('nivel')` trae `played` y `won` en cada fila; `neblla.scores.mine('nivel')` → `{ rating, played, won, drawn, lost, place, history: [{ match, at, result, delta, rating }] }` (las 20 últimas, la más nueva primero); `neblla.scores.of('nivel', userId)` → `{ rating, played, won, place? }`, el nivel de otro (para ver con quién juega). Un resultado decidido tarde por el dev cuenta cuando se decide; uno anulado se quita y el ranking se vuelve a contar partida a partida. `neblla_scores` `setRating` `{ board, player, rating }` fija desde dónde empieza alguien (el nivel que trae de otro club), y sus partidas se recalculan desde ahí; en el panel, «Fijar el nivel» en sus mejores puntuaciones. - **Entre amigos:** `top(ranking, { friends: true })` es el mismo top solo con el usuario y sus amigos de Neblla que juegan a tu app y le dieron el permiso de amigos (la regla de `visible`, abajo); `place` es entre ellos. Pide login y el permiso de amigos del propio usuario (`login_required`, `scope_missing`). - **El torneo diario** (`neblla_set_automation` con `template: 'game.dailyTournament'`) lee este ranking con `board: 'wordle'`: cada día guarda el top del día en `nb_tournaments` y lo manda a tu secreto de API o a una sala como `{ event, board, day, ranking: [{ place, player, value, at, info? }] }`. `day: 'yesterday'` para uno que corre pasada la medianoche UTC. - En una sala el árbitro es otro: `neblla.tools.score` y los testigos (ver servidor.txt). ## Privacidad: publicInfo vs db.data `db.setPublicInfo()` se difunde a todos los miembros de la sala; `neblla.db.data` no llega a otros clientes (ni al peer que ejecuta `room.js`), salvo la parte que marques visible (abajo, «Lo que ven otros usuarios»). ```javascript // ❌ Mal — difunde datos sensibles a toda la sala: neblla.db.setPublicInfo({ name, email: neblla.db.data.email }); // ✅ Bien — solo lo que de verdad es público: neblla.db.setPublicInfo({ name, avatar }); // El email se queda en neblla.db.data, no llega a otros clientes ``` ## Lo que ven otros usuarios Lo que guarda cada usuario es suyo: nadie más lo lee. Si tu app necesita enseñar algo de uno a otros (la colección de una amiga, el perfil de recetas de alguien, las puntuaciones de un amigo), **marcas qué parte y para quién**, y el resto sigue sin salir nunca: ```javascript neblla_update_app({ visible: { data: { cartas: 'friends', faltan: 'friends', 'perfil.nombre': 'all' }, // rutas de db.data tables: { recetas: 'all', cartas: 'friends' }, // las filas de una tabla } }) ``` - **`'all'`**: cualquier usuario de tu app, también un invitado. **`'friends'`**: sus amigos en Neblla (una amistad aceptada), si él dio a tu app el permiso de amigos (`scopes: ['friends']` al hacer login), y él mismo. Si no lo dio, sus amigos lo leen como cualquier otro: un dato `'friends'` no sale y una tabla `'friends'` responde `not_friends`. Lo que no marcas no lo ve nadie, ni sus amigos. - **Rutas de `db.data`**: una clave de arriba (`cartas`) o con puntos (`perfil.nombre`, hasta 5 niveles), 50 como mucho; de una tabla se ven todas sus filas, sin el dueño. Es configuración: va al borrador y llega a los usuarios al publicar. También en el panel, Funciones › Datos y tu base de datos › «Lo que ven otros usuarios». ```javascript // El perfil de una amiga: sus 40 cartas y las 12 que le faltan const amiga = neblla.social.friends[0]; const suyo = await neblla.db.dataOf(amiga); // { cartas: {...}, faltan: [...], perfil: { nombre } } const filas = await neblla.db.loadOf(amiga, { table: 'cartas', orderBy: '-nivel', limit: 50 }); ``` - **`user`** es cualquier id que tu app tenga de esa persona: su id en la app (`appUserId`, el `author.id` de la comunidad), su id en la sala (`neblla.room.users`) o el `id` de un amigo de `neblla.social.friends`; o el objeto mismo. El de un invitado da `user_not_found`: no guarda nada. - **`dataOf`** devuelve solo las rutas que le tocan a quien lee (`{}` si ninguna); **`loadOf`** acepta lo mismo que `db.load` (`where`, `orderBy`, `limit`, `offset`) y responde `not_friends` si la tabla es solo para amigos y no lo es, `not_visible` si la tabla no está marcada. Una app que no marca nada responde `not_visible`. - Se lee donde vive, como el resto: la base de datos de Neblla o tu base de datos (BYODB), y el pre con los datos de pro lee los de pro. Lo que ese usuario guardó y aún espera en su navegador no se ve hasta que sale. Cada lectura cuesta como un mensaje. ## Intercambios entre usuarios Un usuario ofrece algo suyo a otro a cambio de algo de ese otro (una carta por otra, tres gemas por una espada, un regalo sin nada a cambio). **Solo pasa si el otro acepta, y de una vez**: se mueve todo o no se mueve nada, también si algo cambió entre la oferta y la aceptación. Declaras qué se puede intercambiar: ```javascript neblla_update_app({ trade: { tables: ['cartas'], // sus filas cambian de dueño products: ['gemas', 'espada'], // unidades de lo comprado (de un solo pago o consumibles; una suscripción nunca) with: 'friends', // 'friends' (por defecto: solo entre amigos de Neblla) o 'all' days: 3, // lo que dura una oferta si no dice otra cosa (1–30, 3 por defecto) } }) ``` ```javascript // Ana ofrece a Luis su carta del dragón y 3 gemas por la carta del troll de él const oferta = await neblla.db.offer({ to: luis, // cualquier id suyo, como en db.dataOf give: [{ table: 'cartas', id: 'r_dragon' }, { product: 'gemas', amount: 3 }], get: [{ table: 'cartas', id: 'r_troll' }], expires: Date.now() + 86400000, // opcional: hasta 30 días }); // Luis la oye y la ve neblla.db.on('offer', ({ action, offer }) => { /* action: new | done | declined | cancelled | failed */ }); const pendientes = await neblla.db.offers(); // las suyas, pendientes; { status: 'done' } es su registro await neblla.db.acceptOffer(oferta.id); // o declineOffer(id); Ana puede cancelOffer(id) ``` - **Lo que se intercambia**: filas propias de una tabla de `trade.tables` (`{ table, id }`; las de un grupo no) y unidades de un producto de `trade.products` (`{ product, amount }`). Lo que cada usuario guarda con `db.save()` (su documento) **no** se intercambia: lo escriben sus dispositivos, y uno que aún no supiera del cambio volvería a escribir lo de antes. Si algo de ahí tiene que pasar de mano, haz que sea una fila. - **La oferta** comprueba lo que da quien ofrece (que sus filas son suyas, que tiene las unidades) y guarda cómo estaban sus filas: quien la recibe las ve en `give[].row` (sin dueño). Lo que pide no se comprueba ni se enseña hasta que se acepta. - **Aceptar** lo hace todo de una vez: si alguna fila de quien ofreció cambió o ya no es suya (`offer_changed`), si a alguno no le quedan unidades (`insufficient_amount`), si no cabe (`too_many_rows`, el `maxRowsPerPlayer` de la tabla), si ya tiene un producto de un solo pago (`already_owned`) o si tu app dejó de permitirlo (`not_tradable`), no se mueve nada, lo ya movido vuelve y la oferta queda `failed` con su `reason`. Responde con la oferta `done`, y los dos la oyen. - Una oferta solo se responde una vez: aceptada, rechazada, cancelada o caducada, otra respuesta da `offer_closed` (con su `status`) u `offer_expired`. Quien la recibe acepta o rechaza; quien la hizo cancela. Cada usuario tiene como mucho 50 ofertas pendientes (`too_many_offers`). - **Las compras**: el pago se queda con quien pagó. Quien da pierde las unidades (o el producto de un solo pago); quien recibe tiene las suyas, que no son una venta (no salen en Dinero › Ventas ni se reembolsan). Los dos oyen `neblla.shop.on('purchase')` con lo que tienen ahora (`type: 'trade'`). Un producto de dinero real solo se intercambia si lo pones en `trade.products`. - **El registro**: cada intercambio queda un año, con quién, qué, cuándo y cómo acabó. Tú lo ves en el panel (Funciones › Datos › «Intercambios entre usuarios») y con `neblla_trades_read({ appId, action: 'list' })`, donde también cancelas una oferta pendiente. Es configuración: va al borrador y llega a los usuarios al publicar. Cada llamada cuesta como un mensaje. - Errores: `trade_off` (tu app no declara nada), `not_friends`, `self`, `not_owned`, `not_tradable`, `invalid_offer`, `invalid_amount`, `invalid_expires`, `user_not_found`, `offer_not_found`, `login_required` (un invitado no intercambia). ## Copias de seguridad de los datos de tu app Neblla hace cada día una copia cifrada de sus bases de datos, y de ella salen las copias de tu app: las de los **últimos 7 días** (**30 con Pro**), en el panel (Funciones › Datos › Copias de seguridad) y con `neblla_backups_read({ appId, action: 'list' })`. Cada día dice cuántas filas guarda de la publicada y del borrador, por tipo: usuarios, guardados, tablas, buzón, comunidad, chat de las salas, salas y compras. Si Neblla aún no guarda copias, `enabled: false`. **«Restaurar» pone una copia entera en su sitio**, la de la publicada o la del borrador por separado (`neblla_backups({ appId, action: 'restore', date: 'AAAA-MM-DD', env: 'pre' })`; sin `env`, la publicada). Todo lo de ese entorno vuelve a como estaba ese día: los guardados, las tablas, el buzón, la comunidad, el chat y las salas; lo que se escribió después se pierde. Los usuarios que siguen en tu app recuperan su ficha de ese día, pero siguen siendo quienes son (su cuenta de Neblla no cambia). Quien llegó después conserva su ficha, sin datos (la copia no los tiene). Quien fue borrado después (lo pidió él o lo borraste tú) **no vuelve**, ni sus datos: el RGPD manda. Las compras y los clientes de Stripe nunca se cambian ni se borran, porque son cobros de verdad: solo vuelven las de la copia que falten. No se deshace; confírmalo antes. Restaurar usa el mismo bloqueo corto que una migración de BBDD: los usuarios oyen `neblla.app.on('maintenance')` y vuelven a entrar solos en unos segundos. Gasta de la cuota de tu app como una migración (las filas leídas de la copia y escritas, y sus bytes, tipo `migration`); con la cuota del día agotada no empieza (`quota_exceeded`, con `resumesAt`), y una vez empezado termina. Va en segundo plano: `neblla_backups_read({ appId, action: 'status' })` dice si sigue (`running`), si terminó (`done`, con lo que puso por tipo, `erasedSince` y lo gastado en `cost`) o si falló (`failed`, `interrupted` si Neblla se reinició a medias: vuelve a restaurar, empieza de cero). No se puede restaurar mientras la app migra de BBDD (`migrating`) ni dos veces a la vez (`already_restoring`). **Con tu propia BBDD (BYODB), las copias son cosa tuya**: los datos están en tu base, Neblla no los copia y el panel te lo dice (`byodb: true`, sin filas). Haz copias con las herramientas de tu proveedor. ## BBDD propia del desarrollador (BYODB) Los datos sincronizados consumen cuota. Un `db.save()` se cobra por lo que mandas, a la mitad de tarifa que el resto de bytes de payload (con `mode: 'full'`, el documento entero, porque es lo que mandas). Así que guarda poco y cuando cambie algo con sentido, no en cada frame, y recuerda que con BBDD propia configurada (abajo) deja de contar el **almacenamiento**, no la escritura: el payload sigue costando sus bytes porque sigue cruzando el servidor. Para más almacenamiento — o para que los datos vivan en BBDD propia — se aportan credenciales desde el dashboard, o se conecta la base una sola vez en la cuenta o en un proyecto (`neblla_projects` con `connect_db`) y la usan todas sus apps (Explorer usa la de la cuenta; un proyecto puede poner la suya y heredar el resto). Con una base compartida por varias apps, las tablas de cada app llevan su propio prefijo. Engines soportados: ``` Engine Proveedores compatibles (ejemplos) Tier free típico ───────── ───────────────────────────────────────── ──────────────── MongoDB Atlas, DocumentDB, Cosmos DB (Mongo API) Atlas M0 = 512 MB Postgres Neon, Supabase, RDS, Aurora, Render Neon = 3 GB MySQL PlanetScale, RDS, Cloud SQL, Aurora varía Turso Turso.tech (libSQL distribuido) 9 GB ``` **Qué va a tu BBDD**: los blobs JSON que la app genera — `db.save()` (user.data), datos de salas (`room.setData`) y comunicaciones dev↔user. Tres "tablas"/colecciones para datos de usuario (`user_data`, `room_data`, `user_communications`) — y si la app usa Comunidad, esa feature guarda además sus propias tablas en la misma BBDD (**una sola copia, no un espejo**: la base de datos de comunidad es o tu BBDD o la de Neblla, nunca las dos, y sin BBDD propia el sitio por defecto es una Turso de Neblla, no su Mongo) (`community_entries`, `community_entry_tags`, `community_relations`, `community_links`, `community_payloads`, ver comunidad.txt), así que dimensiona también para ellas creadas automáticamente con sus índices la primera vez que Neblla escribe. Esquema estable y documentado. **El chat de tus salas también va a tu BBDD**, en la tabla `neblla_nb_room_messages` (`room`, `sender`, `deleted`, `at` como columnas; el mensaje en el JSON): en claro, para que lo leas con tus consultas, con el id del usuario en tu app y nunca su id de Neblla. Neblla no guarda copia; el tope de mensajes por sala y el límite por minuto se aplican igual. Si tu BBDD no responde, la línea espera en Neblla y se escribe en cuanto vuelve. **También van a tu BBDD tus usuarios**: el registro de los usuarios de tu app (`neblla_app_users`: su id en tu app, su nombre público, email, idioma, cuándo entraron y la última vez, sus baneos, flags y marcas de la comunidad), las compras hechas en tu app y una copia de las amistades entre usuarios de tu app. La ficha de cada usuario en tu app solo está ahí: Neblla guarda un puntero (el id en tu app y de qué cuenta de Neblla es) y lee y escribe la ficha en tu BBDD. Si borras a mano la fila de un usuario, la próxima vez que entre se le hace una ficha nueva y limpia: sin sus baneos, flags ni marcas, porque solo estaban en esa fila; si quieres que siga expulsado, no borres su fila. Si tu BBDD no contesta, entrar en tu app se para con el error `store_unavailable` hasta que vuelve (por cualquier camino de entrada: te llega en `neblla.auth.on('error')` y en `neblla.app.on('error')`, y la ventana de login se lo dice al usuario); quien ya está jugando sigue, y una flag puesta mientras tanto espera en Neblla y se escribe al volver. Las compras hechas en tu app y los clientes de Stripe de tus jugadores (`neblla_app_purchases`, con el id del jugador en tu app en `user_id`, y `neblla_app_customers`) también son solo tuyos: lo que queda de cada compra, sus reembolsos, disputas y suscripciones se leen y se escriben en tu BBDD, y Neblla no guarda copia; las compras de prueba de la pre van con `test = 1`. Si borras a un jugador que tenía compras, sus compras se quedan en tu BBDD y Neblla guarda solo de qué cuenta de Neblla eran, para que las recupere si vuelve. Si tu BBDD no contesta, no se puede comprar ni gastar (`store_unavailable`), y lo que avisa Stripe (pagos, reembolsos, renovaciones) espera en Neblla y se aplica en orden cuando vuelve; lo que lleve 30 días esperando se descarta y habría que aplicarlo a mano desde el panel de Stripe. Si tu app ya tenía BBDD propia antes de este cambio (octubre de 2026), sus fichas, compras y registros siguen en Neblla hasta que Neblla los traslade o hasta tu próxima migración de BBDD. **Los registros de lo que pasa en tu app también son solo tuyos**, en tu BBDD: los casos del anti-trampas (`neblla_nb_fog_cases`, con los estados comparados), los avisos de compras que el dispositivo dijo tener sin pagarlas (`neblla_nb_fraud_reports`, bajo el jugador) y los avisos legales sobre tu comunidad (`neblla_nb_legal_notices`: el caso entero y la dirección postal de quien avisa, sin su nombre, email, organización ni firma). Un caso del anti-trampas se borra solo a los 30 días de abrirse, salvo que actives «Guardar los casos hasta que yo los borre» en el panel (Personas › Moderación) (o `keepFogCases: true` en `neblla_update_app`): entonces se queda hasta que lo borres tú. Neblla guarda solo lo suyo: una copia del caso que anulas mientras lo revisa (la borra al cerrar la revisión) y una copia mínima de cada aviso legal (el aviso, quién lo presentó y lo que se hizo, sin la dirección), que se borra sola a los 3 años de cerrarse el caso. Si tu BBDD no contesta, el backoffice no puede listarlos (`store_unavailable`) y lo que se escribe espera en Neblla. **Las estadísticas de tus jugadores, si las quieres.** Apagadas por defecto: se encienden en la pestaña Estadísticas del panel (o con `playerStats: true` en `neblla_update_app`), que antes de encenderlas te dice cuánto costarán con las visitas de ahora, en euros y en % de la cuota del día (`cost`). Encendidas, Neblla guarda en tu app (con BYODB, en tu BBDD) dos tablas: `neblla_nb_player_stats`, una fila por jugador (id = el del jugador en tu app) con su primera y su última visita, cuántas y de dónde vino la primera (`src`, `cmp`); y `neblla_nb_stats_days`, una fila por día y por semana (UTC) solo con contadores, así que leer 30 días no recorre a tus jugadores. Con eso la pestaña (y `neblla_get_stats`, o `POST /api/backend/apps//stats` con la sesión del panel) enseña los últimos 30 días: activos por día y por semana con la tendencia, nuevos, de dónde vienen (el `utm_source` de la dirección de su primera visita, si no la web de la que llegaron, si no «direct»; «neblla» es la galería; y las campañas, `utm_campaign`), si vuelven (al día siguiente, a los 7 y a los 30 días), el tiempo medio de uso (con la app abierta y a la vista), el dinero por semana y por producto (de las compras y de cada periodo pagado de una suscripción, menos los reembolsos, que restan el día en que se reembolsan) y, si tu app tiene reservas, la ocupación de cada clase y horario; y «Descargar CSV» (`format: 'csv'`), una fila por día. Cuentan los jugadores que hacen login. El SDK no manda ningún mensaje más: de dónde vino la primera visita va con su conexión, y el tiempo lo mide el servidor. Los días son UTC, como la cuota. Cada visita es una escritura en tus tablas (los contadores del día se suman en memoria y se escriben como mucho una vez por minuto), todo gasta de la cuota de tu app como cualquier escritura en tus tablas, también leer la pestaña, y con la cuota del día agotada no se apunta. Son tuyas: Neblla mide aparte lo suyo (las "Apps jugadas" de la cuenta de cada persona) y nunca lee estas tablas; solo para decirte cuánto costarían antes de encenderlas mira cuántos jugadores vinieron estos días. **El email de un usuario registrado va a tu BBDD.** Un invitado solo deja su id. **Cada usuario tiene un id distinto en cada app.** Tu BBDD solo ve el de tu app, así que si se filtrara no se puede cruzar con los usuarios ni las amistades de otras apps. **Qué se queda en Neblla**: lo operativo. La cuenta Neblla de cada persona (con qué hace login, sus sesiones y tokens, que son los mismos en todas las apps), el puntero de cada jugador (su id en tu app y de qué cuenta es) con las reseñas y favoritos de la galería, su propia medición de visitas, la copia de los casos que revisa y la copia mínima de los avisos legales, la configuración de tu app, los ficheros alojados, la cuota y los logs. Migración transparente por fases (copia masiva → delta de cambios → swap final). Se lleva todo lo que la app guarda: guardados, salas y su chat, comunicaciones, la comunidad (entradas con su contenido, valoraciones, favoritos y enlaces), las filas de tus tablas y las de grupos, enlaces compartidos, reservas y créditos; y en el último paso pasan a tu BBDD como originales las fichas de tus usuarios, sus compras y clientes de Stripe, los casos del anti-trampas, los avisos de fraude y los avisos legales (Neblla se queda solo con lo suyo, arriba). El último paso bloquea la app unos segundos para asegurar zero data loss. La app se entera con `neblla.app.on('maintenance')` (`{ reason: 'byodb_migration', retryInSeconds: 30 }`): el socket se desconecta en ~2 segundos y el SDK vuelve a conectar solo pasados `retryInSeconds` (mínimo 5 s); si la app sigue bloqueada, oye otro aviso y lo vuelve a intentar más tarde — muestra un mensaje de mantenimiento mientras tanto. Tras la migración, `db.save()` y los demás blobs van directos a tu BBDD: lo que dejas de gastar es el almacenamiento, no la operación — la escritura sigue cruzando Neblla y sigue costando sus bytes de payload. **Volver a la BBDD de Neblla es otra migración.** Quitar tu BBDD (el botón «Volver a la base de datos de Neblla» del panel, `neblla_byodb({ action: 'to_neblla' })`) no deja la app vacía: es la misma migración por fases al revés, que copia de tu BBDD a donde vive cada cosa sin BBDD propia (las Turso y la Mongo de Neblla) los guardados, las salas y su chat, las comunicaciones, la comunidad, las filas de tus tablas, las fichas de tus usuarios, sus compras y clientes de Stripe, los casos del anti-trampas, los avisos de fraude y los avisos legales, y entrega también lo que esperaba en Neblla para escribirse en tu BBDD. Termina con el mismo bloqueo corto. Lo que queda en tu BBDD no se borra: es tuyo. Si tu BBDD ya no contesta, no se puede copiar nada de ella: la llamada falla con `store_unavailable` y `canStartFresh: true`, y el panel te ofrece «Empezar de cero en la base de datos de Neblla» (`neblla_byodb({ action: 'to_neblla', fresh: true })`): la app sigue con sus jugadores (cada uno con su mismo id en tu app), pero sin sus guardados, fichas, compras ni registros, y lo que esperaba para tu BBDD se descarta. Solo se ofrece mientras tu BBDD no contesta; si vuelve a contestar, migra. **Tus migraciones gastan de la cuota de tu app**, de ida y de vuelta: las lecturas, las escrituras y los bytes que mueven (tipo `migration` en el desglose de la cuota). Si la cuota del día se acaba, la copia se para y sigue sola donde se quedó tras el reinicio diario (00:00 UTC) o en cuanto añades créditos; el bloqueo final siempre termina, aunque pase de la cuota, y lo que gasta también se cobra. El panel y `neblla_byodb_read({ action: 'status' })` dicen cuánto lleva y cuánto le queda (`progress`), lo gastado (`cost`, en créditos) y si espera a la cuota (`pausedForQuota`, `resumesAt`). El traslado que hace Neblla de las fichas y registros de las apps que ya tenían BBDD propia no gasta tu cuota. **Neblla con tu Supabase.** Si ya usas Supabase (o Neon, RDS…), conéctalo con `neblla_byodb` como base Postgres: tus usuarios, sus compras, la comunidad y tus tablas viven ahí, con columnas que consultas con tu SQL. Neblla pone lo que Supabase no trae: cobros y suscripciones, comunidad con moderación, salas, el login (o el tuyo, con BYOAuth), el RGPD y el backoffice. Una sola base, sin puentes entre dos sistemas. ## Escribir a todos tus usuarios Un mensaje a todos los usuarios de tu app, o a los de un grupo (y, dentro, a un papel: «solo los profes de la clase A»), que llega al buzón de cada uno como cualquier mensaje tuyo: el SDK lo avisa como un mensaje nuevo (`neblla.inbox.on('updated')` con `action: 'created'` y `from: 'dev'`), en vivo si está conectado y al volver si no. En el panel es Personas › Usuarios › «Escribir a todos»; con Claude, `neblla_broadcast`. Necesita el módulo `inbox`. **Se paga por destinatario** con la cuota de tu app, como cualquier mensaje a un usuario: el fijo de salida de un mensaje más los bytes del texto y de `data` (tipo `newCommunication`, módulo `inbox` en el desglose). Antes de mandarlo, el servidor te dice cuánto costará: cuántos lo recibirán, cuánto cuesta cada uno y el total, cuánto te queda hoy y a cuántos llega con eso (`reachable`). ``` POST /api/backend/apps//broadcast/estimate { authToken, message, data?, group?, role? } → { recipients, perRecipient, credits, available, ceiling, percentOfDay, fits, reachable } POST /api/backend/apps//broadcast/send (lo mismo) → { broadcast, estimate } POST /api/backend/apps//broadcast/status { authToken, id } → { broadcast } ``` **Con muchos usuarios** el envío contesta al momento y sigue por tandas sin parar nada más (`status`: `sending`, `done`). Nunca se pasa de la cuota: antes de cada mensaje mira lo que queda del día, y si no llega para el siguiente para ahí (`stopped_quota`, con `resumesAt`, el reinicio de las 00:00 UTC) y te lo dice, también en los avisos de la app (`broadcast_paused`, en Personas › Usuarios); tras el reinicio de la cuota sigue sola donde se quedó, sin repetir a nadie, como una migración. Si compras cuota antes, `resume` (`/broadcast/resume`, o `neblla_broadcast` con `action: 'resume'`) sigue al momento. Un usuario terminado no lo recibe ni se cobra (`skipped.terminated`), ni uno con el buzón lleno (`skipped.inboxFull`). Una sola tanda a la vez por app (`broadcast_running`). El mensaje: texto de hasta 5.000 caracteres y `data` opcional, como en `neblla_send_communication`. Quien te escribe sin cuenta desde el formulario de contacto de tu web (social.txt, «Gente sin cuenta») no es un usuario: tiene su hilo en el Buzón y nada más, y «Escribir a todos» no le llega. ## Borrar los datos de un usuario (RGPD) **El marco legal ya está hecho.** Al crear tu primera app aceptas el contrato de encargo (DPA) en https://neblla.com/legal/dpa, y la lista de subencargados, con dónde está cada uno, está en https://neblla.com/legal/subprocessors. Las bases de datos de Neblla están en la UE (Fráncfort e Irlanda), y lo que sale de la UE va cubierto por las cláusulas tipo de la Comisión Europea. Con BYODB, los datos de tu app están donde tú elijas. Lo que un usuario da a tu app es tuyo, y su borrado también es responsabilidad tuya. Neblla te lo pone fácil: desde tu app, `neblla.auth.requestErasure()` abre una ventana de Neblla donde el usuario lo confirma con un clic; ningún script puede hacerlo por él. La petición te llega al backoffice y a tu bandeja, y un botón borra todo lo estándar de Neblla: sus datos guardados, mensajes, registro, reseña y favorita, también en tu BBDD. Lo mismo hace `neblla_erase_user`, y `neblla_export_users` te da sus datos para entregárselos. Si prefieres gestionarlo tú, desactiva la ventana en la configuración de la app. **Si alguien borra su cuenta Neblla, tu usuario se queda con todo lo suyo en tu app, incluido el email con el que se registró.** Ya no puede hacer login con su cuenta Neblla, pero si tu app tiene otra forma de hacer login, ese email te permite reconocerlo y que siga.