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:

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/<appId>/auth/token Authorization: Bearer <clave secreta> { "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.


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:

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.


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:

db.save() y db.data


neblla.db.save({ record: 1250, settings: { sonido: false } });

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.


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

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:


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.


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).


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):


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.


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 });

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.


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

``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.

``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.

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.


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

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.


// 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

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»).


// ❌ 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:


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
} })

// 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 });

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:


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)
} })

// 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)

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/<appId>/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/<appId>/broadcast/estimate  { authToken, message, data?, group?, role? }
→ { recipients, perRecipient, credits, available, ceiling, percentOfDay, fits, reachable }
POST /api/backend/apps/<appId>/broadcast/send      (lo mismo) → { broadcast, estimate }
POST /api/backend/apps/<appId>/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.