# Cómo hacer una app para noback

Vas a escribir una app web que corre en **noback**, una plataforma donde las apps
corren en el navegador de la persona, dentro de un iframe sandbox. noback comprueba
el aislamiento de red antes de ejecutarlas (ver compatibilidad al final). La plataforma se
ocupa de guardar los datos, sincronizarlos entre dispositivos y compartirlos con
otras personas. Vos solo escribís la interfaz y la lógica.

## Publicar desde tu IA, sin configurar nada

Conectá el cliente MCP a `https://apps.noback.page/mcp` (Streamable HTTP)
**sin Authorization**. No hace falta cuenta ni token: leé `get_sdk`, generá el
HTML, validá con `validate_app {html}` y publicá con `create_app {html, name?}`.
`get_sdk` devuelve `{"markdown": ...}` con el mismo contenido que
`https://apps.noback.page/sdk.md` (este archivo). Sin MCP, la persona también
puede pegar o soltar el HTML en `https://noback.app`.

La app recibe un id `a-xxxxxx`, vence a los **7 días** y tiene los límites del
modo anónimo (512 KiB de HTML, 10 publicaciones por hora por IP y sin
`app.net.fetch`). Usá `app.db`: sus datos persisten y se sincronizan igual.

Al publicar, **dale siempre los dos links a la persona**, en palabras simples:

- `openUrl`: «Abrí y compartí tu app». Todos los que abran este link ven y
  editan los mismos datos.
- `claimUrl`: «Para guardarla en tu cuenta antes de que venza». Es privado:
  lleva adentro la clave de edición y permite quedarse con la app, así que va
  solo a la persona que la pidió, nunca a otros. Al abrirlo, noback pide entrar
  o crear una cuenta con passkey o Google; la app pasa a Mis apps y deja de vencer.

Conservá `appId`, `editKey` y el HTML. Si te piden cambios de esa misma app,
usá `update_app {appId, html, editKey}`: conserva datos, link y vencimiento.
No crees otra app para una nueva versión. Nunca pongas `editKey` ni `claimUrl`
dentro del HTML, los datos o el link público: `openUrl` es lo único para compartir.

Con `Authorization: Bearer <token personal>` se publican apps de la cuenta,
sin vencimiento anónimo, y se habilitan herramientas de gestión (`list_apps`,
`get_app`, `delete_app`, etc.). El token es opcional y vive en la configuración
del cliente, nunca en el HTML. Con token, `openUrl` de creación abre una copia
nueva: compartí desde el menú de noback para usar los mismos datos. Sin token
no se pueden usar herramientas de cuenta; `update_app` exige la clave privada.

### Sin cliente MCP (curl)

El servidor es sin estado: cada pedido es un `POST` JSON-RPC independiente a
`https://apps.noback.page/mcp`, sin `Mcp-Session-Id`. Mandá siempre estos headers
(sin el `Accept` doble responde 406):

- `Content-Type: application/json`
- `Accept: application/json, text/event-stream`

La secuencia es `initialize`, `notifications/initialized` y después `tools/call`.
La respuesta es JSON; el resultado de la herramienta viene como texto JSON en
`result.content[0].text`.

```sh
MCP=https://apps.noback.page/mcp
H=(-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream')
curl -s "${H[@]}" $MCP -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
curl -s "${H[@]}" $MCP -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
curl -s "${H[@]}" $MCP -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"create_app","arguments":{"name":"Hola","html":"<!doctype html><html><body><h1>Hola</h1></body></html>"}}}'
```

## Reglas

1. **Un HTML o un paquete `.zip`.** Para apps chicas, CSS y JS inline. Para
   código pesado y recursos propios, usá el formato de paquete de abajo.
2. **Sin red propia.** La app no puede hacer `fetch` externo, `XMLHttpRequest`,
   WebSocket, ni cargar scripts, fuentes o imágenes de internet. Todo eso está
   bloqueado. Si necesitás íconos, usá emojis o SVG escritos en el HTML. Si la
   app de verdad necesita datos de internet (el clima, una cotización), usá
   `app.net.fetch` (ver más abajo): la persona tiene que dar permiso.
3. **Sin CDN.** Las librerías deben ir inline o dentro del paquete; pueden usar
   WASM en el hilo principal, sin `eval`, workers ni almacenamiento propio.
4. **Todo el estado va en `app.db`.** No uses `localStorage`, `sessionStorage`,
   `indexedDB` ni cookies: no funcionan y los datos se perderían. Lo que guardes
   en `app.db` se sincroniza solo y lo ven todas las personas con las que se
   comparte la app.
5. **Diseño mobile-first y legible para gente mayor.** Letra grande (18 px o
   más), botones grandes (44 px de alto o más), buen contraste, textos simples
   y en el idioma de la persona que pidió la app (si no está claro, español),
   sin palabras técnicas.
6. **Datos que duran entre versiones.** Si más adelante te piden mejorar la app,
   mantené los mismos nombres de colección y de campos. Si agregás un campo
   nuevo, tratá su ausencia como un valor por defecto: los datos viejos no lo
   tienen.
7. **Respetá los bloqueos del sandbox** de la tabla siguiente. No intentes
   habilitarlos cambiando permisos ni cargando librerías.

| Bloqueado dentro de la app | Alternativa |
|---|---|
| Workers (`Worker`, `SharedWorker`, service workers) | Procesá en el hilo principal por tandas chicas, cediendo con `setTimeout` para no congelar la interfaz. |
| `eval` y `new Function` (también `Function(...)`) | Escribí funciones JavaScript normales; para datos, usá `JSON.parse`. |
| WASM con threads / SharedArrayBuffer | Usá builds WASM de un solo hilo. `WebAssembly.compile`, `instantiate` y `instantiateStreaming` están permitidos. |
| Descargas con `<a download>` o enlaces `blob:` para descargar | Todavía no existe `app.download`. Mostrá el texto/CSV/JSON en un `textarea` para seleccionar y copiar manualmente. No prometas una descarga; “Llevátela” en el menú de noback exporta la app completa con sus datos. |
| `alert`, `confirm`, `prompt` | Hacé modales propios con HTML (`<dialog>` o elementos de la página). |
| Popups (`window.open`, enlaces con `target="_blank"`) | Mostrá la información dentro de la página; no diseñes flujos que abran otra ventana. |
| `localStorage`, `sessionStorage`, IndexedDB y cookies | Persistí con `app.db`; usá variables para el estado temporal. |
| `fetch` externo, XMLHttpRequest y WebSocket | `fetch("archivo.wasm")` sirve recursos propios; para internet usá `app.net.fetch` con permiso. |

## Paquetes noback (.zip)

Entregá un zip con `index.html` en la raíz, `noback.toml` opcional y sus recursos.
No agregues una carpeta contenedora. Se admiten `.js`, `.mjs`, `.css`, `.wasm`,
`.png`, `.jpg`, `.jpeg`, `.svg`, `.webp`, `.gif`, `.mp3`, `.ogg`, `.wav`, `.json`,
`.woff`, `.woff2`, `.ttf` y `.otf`. No otros HTML, enlaces simbólicos, archivos
especiales, cifrado, ZIP64 ni zips divididos. Máximo 4096 entradas. Las rutas son
relativas, sin `/` inicial, `..`, segmentos vacíos, `\`, `%`, espacios, query ni fragmento.
Los nombres distinguen mayúsculas. Cada versión incluye todos sus recursos y
su descriptor, con un hash inmutable independiente de la compresión del zip.

Límites comprimido **y descomprimido**: 5 MiB anónimo, 25 MiB Hobby, 100 MiB Pro.
`index.html` conserva el límite HTML del plan: 512 KiB / 2 MiB / 5 MiB.
`boxy.toml` se acepta como alias obsoleto para paquetes anteriores; usá solo un descriptor.
`noback.toml` admite hasta 16 KiB. Ejemplo completo:

```toml
nombre = "Remedios de mamá"
descripcion = "Para anotar los remedios y que la familia los vea"
red = ["api.open-meteo.com"]
```

`nombre` es texto no vacío (hasta 80 caracteres), `descripcion` hasta 500.
`nombre` se usa si la publicación no indica un nombre explícito en `name`.
`red` admite hasta 32 dominios exactos sin esquema, puerto ni comodines. No
incluye subdominios automáticamente. `red = []` rechaza toda red mediada.
Sin `red`, sigue el permiso por dominio habitual. La línea inferior pregunta
por cada sitio; si se declara `red`, cualquier dominio fuera de esa lista falla
**sin preguntar**, aun si tenía un permiso guardado. Declarar un sitio no lo autoriza: sigue haciendo
falta consentimiento y un plan con red. Anónimo nunca tiene red mediada.
Claves futuras (`login`, `email`, base compartida, etc.) se ignoran con un aviso;
no habilitan capacidades.

Al publicar, scripts y estilos relativos se incorporan inline; imágenes y audio
de hasta 256 KiB se convierten en `data:`. Los medios mayores se cargan por el
puente como `blob:`; las fuentes en CSS se incrustan como `data:` (no se habilita
`blob:` para fuentes). Se preserva el orden de scripts clásicos con `defer`
ubicándolos al final del body. El HTML transformado tiene un presupuesto de
hasta el doble del límite del paquete; evitá repetir recursos grandes inline.
CSS `url()` resuelve desde la hoja. No se resuelven `@import`, `srcset` relativo
ni imports JS entre archivos: empaquetá el JS en un bundle (clásico o módulo)
y el CSS en una hoja. No incluyas el SDK: noback lo inyecta primero.

Para archivos cargados en ejecución:

```js
await app.ready
const response = await fetch("engine.wasm") // GET relativo, no sale a la red
const module = await WebAssembly.instantiateStreaming(Promise.resolve(response), imports)
const bytes = await app.asset("datos.json") // ArrayBuffer transferido por el puente
const imageURL = await app.assetURL("foto.png") // blob: con el MIME del archivo
imagen.src = imageURL
// Cuando ya no lo uses:
URL.revokeObjectURL(imageURL)
```

El shim de `fetch` acepta strings relativos (`./` inicial opcional), GET y
opciones como `signal`; rechaza URLs absolutas, `Request`, otros métodos y `..`.
Las rutas inexistentes rechazan la promesa. No delega a fetch nativo ni a
`app.net`. Los binarios vienen del player para esa app y hash; la sesión nunca
entra al iframe. Hasta cuatro cargas simultáneas por frame, con el límite de
120 operaciones por segundo del puente. Los binarios de respuesta pueden
superar 1 MiB; los mensajes de entrada conservan su límite.
Abortar un `fetch` rechaza con `AbortError`; la lectura ya iniciada del recurso
en el player puede terminar igualmente.

`XMLHttpRequest` sigue bloqueado; configurá la librería para usar `fetch` y
rutas relativas (`locateFile: path => path` en Emscripten), o pasale `wasmBinary`.
Las librerías que construyen URLs absolutas desde `import.meta.url` requieren
esa configuración. No hay workers, threads, SharedArrayBuffer, OPFS ni storage
del iframe. No prometas compatibilidad automática con todo Godot/Pyodide:
se necesitan builds de un hilo, sin `eval`, y bundles sin imports dinámicos.
La demo SQLite oficial desactiva sus VFS de storage y guarda las notas en `app.db`.

Publicá el zip soltándolo en `https://noback.app`, como un HTML. MCP
`validate_app`, `create_app` y `update_app` aceptan `zipBase64` en lugar de
`html`. No envíes ambos. Con una cuenta, la API `POST /api/publish` también
acepta `{appId, zipBase64, name?}` en lugar de `html`.
Si tenés el repo de noback: `node cli/publish.mjs <appId> app.zip` publica y
`node cli/validate.mjs app.zip [anon|hobby|pro]` valida sin publicar.
“Llevátela” incluye también los recursos del paquete en el HTML autónomo;
“Exportar todo” conserva cada zip original y sus datos por separado.
Los recursos del puente requieren llegar al relay; todavía no se cachean para
ejecutar el paquete sin conexión. La exportación autónoma sí los lleva adentro.
Conservá el zip original para editar y republicar: el chat actual recibe el HTML
transformado y no reconstruye un paquete con sus recursos.

## La API: `window.app`

La plataforma inyecta `window.app` antes de que corra tu código. No hace falta
incluir nada.

```js
await app.ready                       // esperá esto antes de usar el resto

const yo = await app.user()           // { id, name } de quien está usando la app

const tareas = app.db.collection("tareas")

const id = await tareas.add({ texto: "Comprar pan", hecha: false })
await tareas.update(id, { hecha: true })   // cambia solo esos campos
await tareas.remove(id)
const una = await tareas.get(id)           // el objeto, o null
const todas = await tareas.all()           // lista ordenada por createdAt

// watch llama a tu función al principio y cada vez que algo cambia,
// también cuando cambia otra persona o el mismo usuario en otro dispositivo.
const dejarDeMirar = tareas.watch(lista => dibujar(lista))

await app.share()                     // abre el compartir de noback
```

No agregues un botón de compartir propio: noback ya ofrece compartir desde su
pestañita al costado de la app. Usá `app.share()` solo si la persona lo pide.

`update(id, patch)` hace un **merge superficial**: solo cambia los campos
mencionados y conserva los demás. Dos ediciones concurrentes de campos distintos
se fusionan; si editan el mismo campo, gana una de las dos (no necesariamente la
última por hora). Cada texto, lista u objeto anidado se reemplaza entero: no hay
merge profundo ni edición colaborativa de texto. Guardá listas que crecen, como
comentarios, en una colección aparte, con un registro por entrada.

### Cambios de versión y datos

noback guarda un punto de control antes de ejecutar por primera vez cada versión
sobre una copia. El dueño de la copia puede recuperar sus colecciones desde
**más → historial de datos → Volver acá**, y deshacer esa restauración.
Los datos recuperados se sincronizan con quienes usan la misma copia. La historia
y los puntos de control ocupan espacio del plan.

Los puntos viven en metadatos privados del player: `app.db` solo permite acceder
a colecciones; `_boxy`, `__proto__`, `constructor` y `prototype` son nombres
reservados. No intentes acceder a esos metadatos ni guardes tus migraciones ahí.

Mantené compatibles los nombres y campos entre versiones. Una transformación
hecha por tu código puede ejecutarse de nuevo cada vez que abre la app: comprobá
primero el estado de cada registro y evitá borrar los originales. Por ahora no
existe `app.db.migrate`; los puntos de control permiten recuperar datos pero no
convierten automáticamente entre esquemas.

### Internet con permiso: `app.net.fetch`

**Usalo solo si la app necesita datos de internet.** Si la app funciona sin
red, no lo uses. La primera vez que la app pide un sitio, la plataforma le
pregunta a la persona si lo permite. Si dice que no, `app.net.fetch` falla con
un error: mostrá un mensaje amable y dejá que la app siga funcionando.

```js
const res = await app.net.fetch(url, { method: "GET", headers: {}, body: "texto" })
// res = { ok, status, headers, text, json }
// json es el objeto si la respuesta era JSON; si no, null.
```

- Solo direcciones `https`, solo `GET` y `POST`. El `body` va como texto
  (`JSON.stringify(...)`) y la respuesta no puede pasar de 2 MiB.
- Solo funciona con APIs que permiten pedidos desde el navegador (CORS) y que
  no piden clave secreta: una clave escrita en el HTML la ve cualquiera.
- Envolvé cada llamada en `try/catch`: puede fallar por permiso o por
  conexión.
- Usá siempre la misma dirección base: cada sitio distinto es otra pregunta
  para la persona.

Ejemplo con Open-Meteo, que es gratis y no pide clave:

```js
async function clima(ciudad) {
  const geo = await app.net.fetch(
    "https://geocoding-api.open-meteo.com/v1/search?count=1&language=es&name=" + encodeURIComponent(ciudad))
  const lugar = geo.json?.results?.[0]
  if (!lugar) throw new Error("No encontré esa ciudad")
  const pron = await app.net.fetch(
    "https://api.open-meteo.com/v1/forecast?latitude=" + lugar.latitude + "&longitude=" + lugar.longitude +
    "&current=temperature_2m,weather_code&daily=temperature_2m_max,temperature_2m_min&timezone=auto")
  return { lugar: lugar.name, actual: pron.json.current, dias: pron.json.daily }
}
```

Fijate que geocoding y forecast son dos sitios distintos: la persona va a ver
dos preguntas.

- Cada objeto recibe automáticamente `id`, `createdAt` (fecha ISO) y
  `createdBy` (`{ id, name }` de quien lo creó), que la app no puede modificar.
  También recibe `updatedAt` y `updatedBy`: al crear coinciden con la creación
  y cada `update` los actualiza con quien edita y la hora de su dispositivo.
  El SDK ignora estos cinco campos si los enviás en `add` o `update`.
  Los registros anteriores pueden no tener `updatedAt` ni `updatedBy` hasta
  su próxima edición. Son metadatos del SDK, no una auditoría contra clientes
  modificados; las fechas dependen del reloj del dispositivo.
- Los valores tienen que ser datos simples: texto, números, booleanos, `null`,
  listas y objetos. No guardes funciones, fechas `Date` (usá texto ISO) ni
  `undefined`.
- Dibujá la pantalla siempre desde la lista que recibe `watch`. No mantengas una
  copia aparte que se pueda desincronizar.

## Esqueleto

```html
<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Mi app</title>
  <style>
    body { font: 18px/1.5 system-ui, sans-serif; margin: 0; padding: 16px; }
    button { font-size: 18px; min-height: 44px; }
  </style>
</head>
<body>
  <h1>Mi app</h1>
  <ul id="lista"></ul>
  <script>
    app.ready.then(async () => {
      const cosas = app.db.collection("cosas")
      cosas.watch(lista => {
        document.getElementById("lista").innerHTML = ""
        for (const c of lista) {
          const li = document.createElement("li")
          li.textContent = c.texto
          document.getElementById("lista").append(li)
        }
      })
    })
  </script>
</body>
</html>
```

Para una app simple, devolvé solo el HTML completo. Si necesitás recursos aparte,
entregá el paquete zip completo con la estructura indicada.

### Compatibilidad y límites del puente

noback comprueba la navegación del iframe antes de ejecutar la app. Si no puede
comprobar el aislamiento, las apps anónimas no se ejecutan. Las apps de cuentas
muestran un aviso de protección incompleta solo cuando la sonda comprueba una vía
de salida. Si la medición es inconclusa, se reintenta una vez; las apps anónimas
siguen bloqueadas hasta comprobar el aislamiento. Los resultados técnicos se
conservan en los tests, fuera de la interfaz del producto.

El puente acepta hasta 120 operaciones por segundo (incluido el saludo del SDK).
Si se supera, rechaza las operaciones excedentes; la app puede esperar un segundo
y volver a intentar. En juegos y clickers no guardes en cada clic: juntá los
cambios en memoria y escribí cada 1–2 segundos.

```js
let pendiente = null
function guardar(datos) { pendiente = datos }  // en cada clic: solo memoria
setInterval(() => { if (pendiente) { void puntos.update(id, pendiente); pendiente = null } }, 1500)
```

Cada mensaje enviado al player tiene un presupuesto máximo conservador de
1 MiB (texto, claves y estructura incluidos). Los mensajes demasiado grandes,
cíclicos, binarios o de tipos/operaciones desconocidos se ignoran. Dividí cargas
grandes en operaciones chicas. Reemplazar o navegar el iframe invalida su puente,
incluidas las respuestas pendientes.
