# Ejemplos y demos — Episodio 847: «Integrando Valet» Material de cabina del episodio **847 — «Integrando Valet»**, el cierre de la trilogía (843 memoria → 845 herramientas → 847 integración). El corazón es una **demostración real y ejecutada** de la **API de ingesta de notificaciones de Valet** contra una instancia local con una SQLite de usar y tirar. - Guion: [`../guion-0847.md`](../guion-0847.md) - Investigación: [`../investigacion-0847.md`](../investigacion-0847.md) - Repositorio (fuente de verdad): `/data/rust/valet` (público, MIT: `github.com/atareao/valet-ai`) > **Regla de oro:** solo se pega lo que se ha ejecutado de verdad. Lo que no se > pudo probar, se dice al final. Toda la salida cruda está en > [`evidencia/`](evidencia/). **Ningún token real**: los tokens se muestran como > `val_XXXXXXXX<…>` (prefijo + máscara). --- ## Arranque en dos pasos ```bash ./setup.sh # prepara el directorio de trabajo efímero ./00-arranque.sh # levanta el backend con una SQLite de usar y tirar ``` Luego las demos, en orden: `01` … `10`. Para cerrar: `./99-parar.sh`. ### Condiciones de la demo (y por qué) | Decisión | Motivo | |---|---| | `DATABASE_URL=sqlite:/tmp/opencode/valet-847/valet-demo.db` | Base de datos **de usar y tirar**; nunca se toca una BD real. | | `AUTH_ENABLED=false` | Levanta la pila **sin PocketID**: toda `/api/*` opera como usuario `dev`, sin cookie de sesión. | | Binario `target/debug/valet` | El `release` está caducado y falla al migrar por `vec0`. | | Ejecutar **desde la raíz del repo** | Las migraciones se resuelven por **ruta relativa**. | | Sin claves de LLM | No se exigen: cae a Ollama y deja el RAG desactivado. | --- ## Piezas ### `00-arranque.sh` — arranque local limpio - **Qué demuestra:** que Valet arranca sin credenciales de LLM y con la auth desactivada, sobre una SQLite efímera, y que `/api/health` responde. - **Cómo se ejecuta:** `./00-arranque.sh` - **Qué se espera ver:** `{"db":"connected","status":"ok","version":"0.15.0"}` y en el log `Valet server listening on 127.0.0.1:3458`. - **Evidencia:** [`evidencia/00-arranque.txt`](evidencia/00-arranque.txt) ### `01-crear-clave.sh` — crear una clave de ingesta (token revelado una vez) - **Qué demuestra:** `POST /api/ingest-keys` → `201` con `{id, name, prefix, token}`. El `token` se **enmascara** a prefijo + `<…>`. - **Cómo se ejecuta:** `./01-crear-clave.sh [nombre]` (por defecto `backup-host`) - **Qué se espera ver:** `201` y un token de forma `val_` + 43 chars base64url (47 totales); `prefix` = primeros 12 caracteres. - **Evidencia:** [`evidencia/01-crear-clave.txt`](evidencia/01-crear-clave.txt) ### `02-enviar-notificacion.sh` — ingesta con `Authorization: Bearer` - **Qué demuestra:** `201` con `{"id"}`; sin cabecera y con token inventado, `401` con **el mismo cuerpo** (`{"error":"unauthorized"}`). - **Cómo se ejecuta:** `./02-enviar-notificacion.sh` - **Evidencia:** [`evidencia/02-enviar-notificacion.txt`](evidencia/02-enviar-notificacion.txt) ### `03-leer-buzon.sh` — leer el buzón - **Qué demuestra:** `GET /api/notifications/unread?limit=20` devuelve la notificación con `metadata` ya parseado. - **Cómo se ejecuta:** `./03-leer-buzon.sh` - **Evidencia:** [`evidencia/03-leer-buzon.txt`](evidencia/03-leer-buzon.txt) ### `04-marcar-leidas.sh` — marcar como leídas (idempotente) - **Qué demuestra:** `{"marked":1}` y, al repetir, `{"marked":0}`. - **Cómo se ejecuta:** `./04-marcar-leidas.sh` - **Evidencia:** [`evidencia/04-marcar-leidas.txt`](evidencia/04-marcar-leidas.txt) ### `05-revocar.sh` — revocar y comprobar el `401` - **Qué demuestra:** `DELETE` → `204` (idempotente), token revocado → `401`, y la clave sigue en el listado con `revoked_at` (no se borra). - **Cómo se ejecuta:** `./05-revocar.sh` - **Evidencia:** [`evidencia/05-revocar.txt`](evidencia/05-revocar.txt) ### `06-casos-limite.sh` — los límites que cita el guion - **Qué demuestra:** `409` (nombre duplicado), `400` (`level` con espacios, `message` vacío, `metadata` no objeto, `message` > 4096, `ids` null/[]), `415` (sin `Content-Type`) y `413` (cuerpo > 64 KiB). - **Cómo se ejecuta:** `./06-casos-limite.sh` - **Evidencia:** [`evidencia/06-casos-limite.txt`](evidencia/06-casos-limite.txt) ### `07-webpush.sh` — VAPID y suscripción - **Qué demuestra:** `GET /api/push/vapid-key` genera el par en la **primera** llamada y lo reutiliza; `POST /api/push/subscriptions` con un `p256dh` real de 65 bytes y un `auth` de 16 bytes → `201`; upsert conserva el `id`. - **Cómo se ejecuta:** `./07-webpush.sh` (necesita `python3` + `cryptography`) - **Evidencia:** [`evidencia/07-webpush.txt`](evidencia/07-webpush.txt) ### `08-paginacion-keyset.sh` — el `next_cursor` real - **Qué demuestra:** con `limit=1` y 3 avisos, `next_cursor` **no es null** (`"|"`), y el cursor se encadena URL-encodeado. - **Cómo se ejecuta:** `./08-paginacion-keyset.sh` - **Evidencia:** [`evidencia/08-paginacion-keyset.txt`](evidencia/08-paginacion-keyset.txt) ### `09-trampa-doc-codigo.sh` — la trampa de la documentación - **Qué demuestra:** (A) `mark-read` **sin** `ids` marca **todas** (la doc dice `400`); (B) el buzón **sí** pagina (la doc dice `next_cursor` siempre `null`). Contrasta cada punto con su fichero y línea del repo. - **Cómo se ejecuta:** `./09-trampa-doc-codigo.sh` - **Evidencia:** [`evidencia/09-trampa-doc-codigo.txt`](evidencia/09-trampa-doc-codigo.txt) ### `10-logs.sh` — el motivo real, solo en el log - **Qué demuestra:** que `missing_authorization`, `malformed_authorization` y `unknown_or_revoked_key` aparecen **solo en el log**, nunca en la respuesta. - **Cómo se ejecuta:** `./10-logs.sh` - **Evidencia:** [`evidencia/10-logs-servidor.txt`](evidencia/10-logs-servidor.txt) ### Chuletas - [`curl/README.md`](curl/README.md) — las peticiones en el orden de la demo, con `` donde iría el token. - [`resumen-comandos.md`](resumen-comandos.md) — resumen de una línea por comando. --- ## Evidencia real (resultados clave) Todo lo siguiente se ejecutó en este equipo el **22/10/2026** contra la instancia local descrita arriba. Detalle completo en [`evidencia/`](evidencia/). | # | Paso | Resultado real | |---|---|---| | 0 | `GET /api/health` | `200` `{"db":"connected","status":"ok","version":"0.15.0"}` | | 1 | `POST /api/ingest-keys` `backup-host` | `201`, token `val_83xGOtBB<…>`, `prefix=val_83xGOtBB` | | 2 | `POST /api/notifications` con el token | `201` `{"id":"65879b20-…"}` | | 2b | ingesta sin cabecera | `401` `{"error":"unauthorized"}` | | 2c | ingesta con token inventado | `401` `{"error":"unauthorized"}` (idéntico) | | 2d | cabecera mal formada (sin `Bearer `) | `401` `{"error":"unauthorized"}` (idéntico) | | 3 | `GET …/unread?limit=20` | `200`, la notificación con `metadata` parseado | | 4 | `POST …/mark-read` con el id | `200` `{"marked":1}`; repetido → `{"marked":0}` | | 5 | `DELETE /api/ingest-keys/{id}` | `204`; repetido → `204`; token → `401` | | 6a | `POST /api/ingest-keys` nombre duplicado | `409` `{"error":"an ingest key with that name already exists"}` | | 6b | ingesta `level:" info "` | `400` `{"error":"level must be one of info, warning or critical"}` | | 6c | `mark-read` `ids:null` | `400` `{"error":"ids must not be null"}` | | 6d | `mark-read` `ids:[]` | `400` `{"error":"ids must not be empty"}` | | 6e | ingesta sin `Content-Type` | `415` | | 6f | ingesta cuerpo > 64 KiB | `413` | | 6g | `metadata:[1,2]` | `400` `{"error":"metadata must be a JSON object"}` | | 6h | `message` > 4096 bytes | `400` `{"error":"message must be at most 4096 bytes"}` | | 7a | `GET /api/push/vapid-key` (1ª y 2ª) | `200`, mismo `public_key` (par perezoso) | | 7b | `POST /api/push/subscriptions` válida | `201` `{"id":"c2ceb43b-…"}`; mismo endpoint → mismo `id` | | 7c | `endpoint` http:// | `400` `{"error":"endpoint must use the https scheme"}` | | 7d | `p256dh` mal formado | `400` `{"error":"p256dh must decode to 65 bytes"}` | | 8 | `unread?limit=1` con 3 avisos | `200`, `next_cursor:"2026-10-22 19:03:44\|4"` (no null) | | 9A | `mark-read` sin `ids` | `200` `{"marked":N}` (en la última ejecución, `5`) — **marca todas** (la doc dice `400`) | | 9B | doc vs código paginación | doc: `next_cursor` siempre null · código: keyset real | | 10 | log del servidor | `reason=missing_authorization` / `malformed_authorization` / `unknown_or_revoked_key` | **Dato de forma del token:** `val_` + 43 caracteres base64url = **47** caracteres (32 bytes sin relleno); `prefix` = 12 caracteres. **Confirmado en el listado:** `last_used_at` se rellena tras una ingesta correcta; `revoked_at` se rellena al revocar y la fila **no** se borra. --- ## Qué no se pudo probar (y por qué) 1. **La frontera de sesión OIDC no se ejercitó.** La demo va con `AUTH_ENABLED=false`, así que *todas* las rutas `/api/*` operan como usuario `dev` y **no** se envió ninguna cookie `valet_session`. En consecuencia, este material **no demuestra** que `unread`, `mark-read`, `ingest-keys` y `push` exijan sesión: con la auth apagada, no la exigen. La afirmación del guion («quien lee el buzón eres tú, con sesión») se sostiene en el **código** (`src/middleware/auth.rs:72-80`, exención por par método+path), no en esta ejecución. No se levantó PocketID (es un proveedor externo, no parte del stack). 2. **No hubo entrega WebPush real.** La suscripción de ejemplo apunta a `https://push.example.test/…` (dominio reservado para documentación). No se envió a un servicio de push real ni se abrió un navegador que recibiera el aviso. El despacho es *best-effort* y en segundo plano: aquí no se pudo observar su efecto. 3. **La generación concurrente del par VAPID no se reprodujo en vivo.** El código la resuelve en una transacción con `INSERT … ON CONFLICT … DO UPDATE` (probado en el propio repo con una barrera). Aquí solo se verificó que la 2ª llamada devuelve el mismo par que la 1ª. 4. **El fallo del binario `release` (`vec0`) no se reprodujo.** Se documenta desde la investigación y el guion; en esta demo se usó directamente el `debug`. 5. **No se probaron**: rotación de claves (Valet no rota, solo revoca), expiración (no existe TTL), ni la baja de suscripción vía `DELETE` como comprobación de persistencia más allá del `204` (sí se ejecutó el `204`). --- ## Requisitos `curl`, `python3` y —solo para `07-webpush.sh`— el módulo `cryptography`. Nada más: todo vive en el directorio de trabajo efímero y el servidor se para con `./99-parar.sh`.