{"proyecto":"pulso","version":"v0.0.2","entorno":"uat","fases":[{"fase":{"n":0,"nombre":"Fundaciones","pregunta":"¿Hay dónde construir y cómo mirarlo?","entrega":["Worker desplegado en una URL viva con health público","D1 pulso-db / pulso-obra, KV pulso-cache, R2 pulso-media (uat y producción separados)","Este panel de obra, con los veredictos persistidos en D1 pulso-obra","Dependencias fijadas exactas, con un test que falla si alguien las desfija","Entrada por magic link: sin contraseña, sesión firmada con HMAC. Se adelantó aquí porque la auth era el bloqueo del proyecto y había que probarla el día 1","Esquema completo de D1 aplicado (la fase 1 solo tendrá que sembrar y consultar)"],"criterios":[{"id":"f0.health","tipo":"auto","enunciado":"GET /v1/public/health responde 200 con status ok, versión y entorno","fallaSi":"No responde 200, o el JSON no trae version y environment"},{"id":"f0.pins","tipo":"auto","enunciado":"Ninguna dependencia usa rango (^, ~, *, x): todas fijadas exactas","fallaSi":"Cualquier versión en package.json no es exacta"},{"id":"f0.obra-persiste","tipo":"auto","enunciado":"Un veredicto enviado al panel se guarda en D1 y reaparece al recargar","fallaSi":"El veredicto no vuelve en GET /obra/api/estado"},{"id":"f0.plan-coherente","tipo":"auto","enunciado":"Toda fase tiene pregunta, al menos un criterio auto y al menos uno humano","fallaSi":"Una fase sin pregunta o sin gate humano"},{"id":"f0.sesion-firmada","tipo":"auto","enunciado":"Una cookie de sesión manipulada, caducada o mal formada se rechaza","fallaSi":"Cualquiera de los tres casos entra"},{"id":"f0.magic-un-uso","tipo":"auto","enunciado":"Un magic link usado dos veces no entra la segunda, y caduca a los 15 minutos","fallaSi":"El mismo enlace sirve dos veces"},{"id":"f0.magic-solo-permitidos","tipo":"auto","enunciado":"A un correo que no está en la lista no se le envía nada, y la respuesta es idéntica: no se filtra quién tiene acceso","fallaSi":"La respuesta permite distinguir un correo con acceso de uno sin acceso"},{"id":"f0.url-viva","tipo":"humano","enunciado":"Abro la URL desde el móvil y el panel se lee sin hacer zoom ni scroll lateral","fallaSi":"Tengo que hacer zoom, o algo se sale de la pantalla"},{"id":"f0.entro-por-correo","tipo":"humano","enunciado":"Pido el enlace en /entrar, me llega al correo, lo toco y entro. Ya puedo marcar veredictos","fallaSi":"No llega, llega a spam, o al tocarlo no entro"},{"id":"f0.plan-es-el-mio","tipo":"humano","enunciado":"El plan de 7 fases que veo es el que quiero para Pulso, en ese orden","fallaSi":"Falta algo importante, o el orden no tiene sentido para mí"},{"id":"f0.veredicto-facil","tipo":"humano","enunciado":"Puedo marcar ✓/✗ y dejar un comentario en tres toques, sin teclear nada raro","fallaSi":"Me cuesta encontrar dónde opinar, o se pierde lo que escribo"}],"guion":[{"n":1,"haz":"Abre https://uat-api.pulso.travel/entrar en el móvil, con datos, no en wifi.","debePasar":"Sale un campo de correo. El teclado del móvil aparece con la arroba a mano."},{"n":2,"haz":"Escribe lc@cooweb.co y toca «Enviarme el enlace».","debePasar":"Sale un mensaje verde diciendo que si ese correo tiene acceso, le llega un enlace."},{"n":3,"haz":"Abre el correo. Mira también en spam si no lo ves en un minuto.","debePasar":"Ha llegado un correo de Pulso con un botón «Entrar»."},{"n":4,"haz":"Toca «Entrar» en el correo.","debePasar":"Vas directo al panel de obra, ya dentro, y arriba aparece tu correo."},{"n":5,"haz":"Sin girar el móvil, desliza de arriba abajo toda la página del panel.","debePasar":"Nada se sale por el lado. No hay barra de scroll horizontal ni hace falta zoom."},{"n":6,"haz":"Despliega la Fase 0 y lee los criterios del gate.","debePasar":"Entiendes cada criterio sin preguntarme qué significa."},{"n":7,"haz":"Marca ✓ o ✗ en cada criterio humano de la Fase 0. En al menos uno, escribe un comentario.","debePasar":"El botón se queda marcado. Aparece «guardado» con la hora."},{"n":8,"haz":"Vuelve a tocar el enlace del correo, el mismo de antes.","debePasar":"Dice que ese enlace ya no vale. Un magic link se usa una vez y solo una."},{"n":9,"haz":"Cierra el navegador del todo y vuelve a abrir https://uat-api.pulso.travel/obra","debePasar":"Sigues dentro y tus ✓/✗ y tu comentario siguen ahí. Nada se ha perdido."},{"n":10,"haz":"Lee la sección «Pendiente de atender» arriba del panel.","debePasar":"Tu comentario y cualquier ✗ aparecen ahí, esperando respuesta."}]},"estado":"cerrada","entrega":{"id":"e0v002","fase":0,"version":"v0.0.2","url":"https://uat-api.pulso.travel/entrar","changelog":"Fase 0 micro 2 · Entrada por magic link sin contraseña, sesión firmada con HMAC, esquema completo de D1 aplicado, y el panel de obra protegido por tu sesión en vez de por una clave en la URL. 84 tests en verde.","commit_sha":null,"entregada_en":"2026-08-17 13:59:24"},"veredictos":{"f0.url-viva":{"id":"WkvRO3g1p4Zr","fase":0,"criterio_id":"f0.url-viva","veredicto":"ok","comentario":null,"autor":"Luis","version":"v0.0.1","atendido":1,"atendido_nota":null,"created_at":"2026-08-17 13:00:43"},"f0.entro-por-correo":{"id":"OphZMxRhEgei","fase":0,"criterio_id":"f0.entro-por-correo","veredicto":"ok","comentario":null,"autor":"lc@cooweb.co","version":"v0.0.2","atendido":1,"atendido_nota":null,"created_at":"2026-08-17 14:09:08"},"f0.plan-es-el-mio":{"id":"9ldJjjzKbOE8","fase":0,"criterio_id":"f0.plan-es-el-mio","veredicto":"ok","comentario":null,"autor":"lc@cooweb.co","version":"v0.0.2","atendido":1,"atendido_nota":null,"created_at":"2026-08-17 14:09:12"},"f0.veredicto-facil":{"id":"eBNLHLDhPmc4","fase":0,"criterio_id":"f0.veredicto-facil","veredicto":"ok","comentario":null,"autor":"lc@cooweb.co","version":"v0.0.2","atendido":1,"atendido_nota":null,"created_at":"2026-08-17 14:09:15"}},"humanosTotal":4,"humanosOk":4},{"fase":{"n":1,"nombre":"El feed, con datos sembrados","pregunta":"¿Esto se entiende y apetece?","entrega":["Esquema D1 completo de Pulso y migración aplicada","20 eventos reales de Castellón sembrados a mano, con sus locations","GET /v1/events, /events/:id, /events/nearby con bounding box + Haversine","Vista del feed a 360 px, sin auth, para juzgar si apetece"],"criterios":[{"id":"f1.eventos-listan","tipo":"auto","enunciado":"GET /v1/events?city=Castellón devuelve los eventos sembrados paginados"},{"id":"f1.nearby-correcto","tipo":"auto","enunciado":"GET /v1/events/nearby filtra por radio real (Haversine), verificado con distancias conocidas"},{"id":"f1.geo-test","tipo":"auto","enunciado":"Haversine y bounding box tienen test con coordenadas de Castellón y distancias conocidas"},{"id":"f1.apetece","tipo":"humano","enunciado":"Miro el feed y me apetece tocar algo. No parece una tabla de base de datos","fallaSi":"Lo veo y no me dice nada, o no sé qué hacer con él"},{"id":"f1.entiendo-evento","tipo":"humano","enunciado":"De un evento entiendo qué es, cuándo y dónde sin abrirlo","fallaSi":"Tengo que abrir el detalle para saber si me interesa"},{"id":"f1.datos-crebles","tipo":"humano","enunciado":"Los 20 eventos me parecen de verdad de Castellón, no de relleno","fallaSi":"Reconozco que son inventados"}],"guion":[]},"estado":"pendiente","entrega":null,"veredictos":{},"humanosTotal":3,"humanosOk":0},{"fase":{"n":2,"nombre":"MCP y scraping real","pregunta":"¿La pieza cara funciona y trae datos de verdad?","entrega":["Servidor MCP en mcp.pulso.travel con las 9 tools y 4 resources","Scraper real de vivecastellon.com con HTMLRewriter","Browser Rendering como plan B para las fuentes que van por JS (castello.es, dipcas.es)","source_hash para detectar cambios sin duplicar"],"criterios":[{"id":"f2.mcp-lista-tools","tipo":"auto","enunciado":"Un cliente MCP se conecta y lista las 9 tools y los 4 resources"},{"id":"f2.scrape-real","tipo":"auto","enunciado":"El scraper extrae ≥10 eventos reales de vivecastellon.com con título y fecha","fallaSi":"Extrae menos de 10, o alguno sin fecha parseable"},{"id":"f2.sin-duplicar","tipo":"auto","enunciado":"Scrapear dos veces la misma fuente no duplica eventos (source_hash)"},{"id":"f2.desde-claude","tipo":"humano","enunciado":"Desde Claude pregunto \"¿qué hay este finde en Castellón?\" y me contesta con eventos reales","fallaSi":"No conecta, o inventa eventos que no existen"},{"id":"f2.eventos-son-reales","tipo":"humano","enunciado":"Cojo 3 eventos que trajo el scraper y compruebo que existen de verdad","fallaSi":"Alguno no existe, o la fecha o el sitio están mal"}],"guion":[]},"estado":"pendiente","entrega":null,"veredictos":{},"humanosTotal":2,"humanosOk":0},{"fase":{"n":3,"nombre":"Con memoria: quién eres y qué te gusta","pregunta":"¿Recuerda lo que debe?","entrega":["Preferencias, ubicación y modo acompañante persistidos","GET /v1/events/for-you cruzando preferencias × ubicación × compañía","Zero Trust conectado de verdad: Access Application sobre pulso-travel, IdP y service token. Solo falta el API token con Access:Edit"],"criterios":[{"id":"f3.access-jwt-listo","tipo":"auto","enunciado":"Con AUTH_MODE=access-jwt, un JWT sin firmar, caducado o con audience ajeno recibe 401, validado contra el JWKS real de pulso-travel","fallaSi":"Alguno de los tres casos entra"},{"id":"f3.upsert-idempotente","tipo":"auto","enunciado":"El mismo usuario entrando dos veces no crea dos filas"},{"id":"f3.for-you-ordena","tipo":"auto","enunciado":"for-you ordena por relevance × peso de preferencia, verificado con pesos fijados"},{"id":"f3.entro-con-google","tipo":"humano","enunciado":"Entro con mi Google por Zero Trust y la app sabe quién soy sin que le diga nada","fallaSi":"Me pide algo más, o no me reconoce al volver"},{"id":"f3.me-conoce","tipo":"humano","enunciado":"Marco mis gustos, vuelvo mañana y el feed sigue ajustado a ellos","fallaSi":"Se olvidó de lo que le dije"}],"guion":[]},"estado":"pendiente","entrega":null,"veredictos":{},"humanosTotal":2,"humanosOk":0},{"fase":{"n":4,"nombre":"Con tiempo: crons y digest","pregunta":"¿Aguanta el paso de los días?","entrega":["Cron de scraping diario 03:00 UTC vía Queue","Digest matutino pre-computado en KV a las 06:00","Cron de recordatorios cada 5 min y limpieza semanal","Días simulados: 7 ejecuciones seguidas sin duplicar ni romper"],"criterios":[{"id":"f4.siete-dias","tipo":"auto","enunciado":"Simular 7 días de crons no duplica eventos ni deja tareas colgadas","fallaSi":"Crecen los duplicados, o alguna tarea queda en running para siempre"},{"id":"f4.digest-precomputado","tipo":"auto","enunciado":"GET /v1/users/me/digest se sirve de KV, no recalcula"},{"id":"f4.cleanup-archiva","tipo":"auto","enunciado":"La limpieza archiva eventos de hace más de 7 días y no toca los vivos"},{"id":"f4.digest-util","tipo":"humano","enunciado":"Leo el digest de la mañana y me sirve. No es un listado, es un plan","fallaSi":"Es un volcado de eventos sin criterio"},{"id":"f4.sigue-vivo","tipo":"humano","enunciado":"Vuelvo tres días después sin tocar nada y hay eventos nuevos","fallaSi":"La base está igual que la dejé"}],"guion":[]},"estado":"pendiente","entrega":null,"veredictos":{},"humanosTotal":2,"humanosOk":0},{"fase":{"n":5,"nombre":"El circuito entero: tiempo real y avisos","pregunta":"¿Funciona de punta a punta?","entrega":["Durable Object EventChannel con WebSocket y ring buffer de 50","Web Push real con VAPID (esto sí es gratis, va de verdad)","Durable Object SocialListener contra stub determinista (no hay claves de X aún)","Media a R2, servida por media.pulso.travel"],"criterios":[{"id":"f5.ws-broadcast","tipo":"auto","enunciado":"Dos clientes WS conectados al mismo evento reciben el mismo broadcast"},{"id":"f5.ring-buffer","tipo":"auto","enunciado":"Al reconectar, el cliente recibe los mensajes que se perdió (hasta 50)"},{"id":"f5.push-cifrado","tipo":"auto","enunciado":"El payload de Web Push se cifra correctamente (ECDH + VAPID) y el push service lo acepta"},{"id":"f5.stub-honesto","tipo":"auto","enunciado":"El social listening en modo stub lo dice en su respuesta: nunca finge ser real","fallaSi":"Devuelve datos inventados sin marcarlos como stub"},{"id":"f5.push-llega","tipo":"humano","enunciado":"Me suscribo a un evento desde el móvil y me llega la notificación push de verdad","fallaSi":"No llega, llega tarde, o llega sin decir de qué evento es"},{"id":"f5.tiempo-real-se-nota","tipo":"humano","enunciado":"Con la app abierta pasa algo en un evento y lo veo aparecer sin recargar","fallaSi":"Tengo que recargar para verlo"}],"guion":[]},"estado":"pendiente","entrega":null,"veredictos":{},"humanosTotal":2,"humanosOk":0},{"fase":{"n":6,"nombre":"El canal real","pregunta":"¿Alguien lo quiere de verdad?","entrega":["Dominios finales api / mcp / media .pulso.travel en producción","Docs con Scalar en /docs, público","Rutas entre eventos (nearest neighbour + Nominatim, sin Google)","Hot scoring y rate limiting"],"criterios":[{"id":"f6.dominios-vivos","tipo":"auto","enunciado":"api., mcp. y media.pulso.travel responden en producción con TLS válido"},{"id":"f6.docs-publicas","tipo":"auto","enunciado":"/docs y /v1/openapi.json responden sin auth y el spec valida"},{"id":"f6.ruta-tiene-sentido","tipo":"auto","enunciado":"La ruta entre 3 eventos no cruza la ciudad dos veces (orden mejor que el de entrada)"},{"id":"f6.lo-usaria","tipo":"humano","enunciado":"Un fin de semana lo uso yo de verdad para decidir qué hacer, sin forzarme","fallaSi":"Abro otra cosa para decidir"},{"id":"f6.se-lo-daria-a-alguien","tipo":"humano","enunciado":"Le paso la URL a alguien de Castellón sin avisar de que es un PoC","fallaSi":"Me da vergüenza enseñarlo"}],"guion":[]},"estado":"pendiente","entrega":null,"veredictos":{},"humanosTotal":2,"humanosOk":0}],"pendientes":[],"hallazgos":[{"id":"h0zerotrust","fase":0,"asunto":"Zero Trust está disponible pero sin configurar","resultado":"Zero Trust SÍ está disponible en la cuenta: access/identity_providers, access/apps y access/service_tokens responden success:true. Pero están vacíos: 0 IdPs, 0 aplicaciones, 0 service tokens. Y cooweb.cloudflareaccess.com da 404, así que el team domain no es el que asume el PRD. Falta: nombre del team domain + API token con Access:Edit. Ojo: pulso.cloudflareaccess.com ya está cogido por otra cuenta.","evidencia":"GET /accounts/45e3f.../access/identity_providers → success:true, 0 resultados · access/apps → 0 · access/service_tokens → 0 · curl cooweb.cloudflareaccess.com/cdn-cgi/access/certs → 404","bloquea":1,"created_at":"2026-08-17 12:47:16"},{"id":"h0dominios","fase":0,"asunto":"Los dominios no estaban configurados","resultado":"El GOAL daba por hechos api/mcp/media.pulso.travel, pero ninguno resolvía. La zona pulso.travel sí está en Cloudflare. uat-api.pulso.travel se creó como Custom Domain del Worker: el scope workers_routes bastó, no hizo falta zone:write.","evidencia":"dig +short api.pulso.travel → vacío · NS de pulso.travel: chance/olga.ns.cloudflare.com","bloquea":0,"created_at":"2026-08-17 12:47:16"},{"id":"h0castello","fase":0,"asunto":"La agenda del Ayuntamiento no es scrapeable con HTMLRewriter","resultado":"castello.es/es/agenda-cultural devuelve 200 y 172 KB, pero es un shell de Liferay: los eventos entran por JS. Los h3 del HTML son cromo del portal, no eventos. Plan B: Browser Rendering, que la cuenta sí tiene habilitado.","evidencia":"GET castello.es/es/agenda-cultural → 200, 0 eventos extraíbles del HTML","bloquea":0,"created_at":"2026-08-17 12:47:16"},{"id":"h0dipcas","fase":0,"asunto":"La Diputació tampoco sirve eventos en el HTML","resultado":"dipcas.es/es/eventos/public responde 200 pero el div listado-eventos llega vacío. Mismo plan B que castello.es.","evidencia":"GET dipcas.es/es/eventos/public → 200, div listado-eventos sin hijos","bloquea":0,"created_at":"2026-08-17 12:47:16"},{"id":"h0vivecastellon","fase":0,"asunto":"vivecastellon.com sí es scrapeable: es la fuente piloto","resultado":"Server-rendered, 12 eventos reales extraídos en la primera llamada, con título y enlace. Se usará como fuente piloto de la fase 2 en lugar de la web municipal.","evidencia":"GET vivecastellon.com/eventos-castellon-agenda/ → 200; ej: «27º Festival Bagatge de teatro de calle y circo en Betxì», Teatro Principal, MBACAS, EACC","bloquea":0,"created_at":"2026-08-17 12:47:16"},{"id":"h0terceros","fase":0,"asunto":"No hay claves de terceros: lo que dependa de ellas será stub declarado","resultado":"Sin X API, Meta Graph, Google Maps ni WhatsApp. Social listening y rutas se construyen contra stubs que se identifican como tales en su respuesta. Geocoding con Nominatim (OSM) y rutas con nearest-neighbour. Web Push sí es gratis con VAPID: eso va de verdad.","evidencia":"Decisión de Luis, 17/08/2026","bloquea":0,"created_at":"2026-08-17 12:47:16"},{"id":"h0versiones","fase":0,"asunto":"Versiones consultadas en npm hoy y fijadas exactas","resultado":"hono 4.13.2, drizzle-orm 0.45.2, zod 4.4.3, nanoid 6.0.1, jose 6.2.9, @modelcontextprotocol/sdk 1.30.0, wrangler 4.123.0, typescript 6.0.3. El SDK de MCP acepta zod ^3.25 || ^4.0. Scalar pide hono ^4.12.5. TypeScript 7.0.2 existe ya, pero se fija 6.0.3 para no estrenar el compilador nuevo en un PoC.","evidencia":"npm view <paquete> version · test/pins.test.ts falla si alguien desfija algo","bloquea":0,"created_at":"2026-08-17 12:47:16"},{"id":"h0vitest","fase":0,"asunto":"vitest-pool-workers 0.21 cambió de API","resultado":"Ya no existe defineWorkersConfig ni @cloudflare/vitest-pool-workers/config. Ahora es un plugin de Vite: cloudflareTest(). Tampoco existe la opción singleWorker. Se descubrió leyendo los tipos del paquete, no de memoria.","evidencia":"node_modules/@cloudflare/vitest-pool-workers/dist/pool/index.d.mts → exporta cloudflareTest, cloudflarePool","bloquea":0,"created_at":"2026-08-17 12:47:16"},{"id":"h0ztequipo","fase":0,"asunto":"El equipo Zero Trust es pulso-travel, no cooweb","resultado":"pulso-travel.cloudflareaccess.com existe y sirve 2 claves RS256. El PRD apuntaba a cooweb, que da 404. El validador de JWT ya está escrito contra este issuer y hay un test que comprueba que el JWKS real responde. Sigue faltando la Access Application y ACCESS_AUD, que necesitan un API token con Access:Edit: mi token OAuth de wrangler puede LEER Access pero no escribir (auth.forbidden), y no existe ningún scope de Access en wrangler login --scopes-list. Aviso: pulso.cloudflareaccess.com ya está cogido por otra cuenta.","evidencia":"GET pulso-travel.cloudflareaccess.com/cdn-cgi/access/certs → 200, 2 claves RS256 · POST access/identity_providers → 1010 auth.forbidden","bloquea":0,"created_at":"2026-08-17 13:59:24"},{"id":"h0email","fase":0,"asunto":"El magic link funciona: el correo llega a la bandeja de entrada","resultado":"pulso.travel está dado de alta para envío (SPF v=spf1 include:_spf.mx.cloudflare.net y DKIM en cf2024-1._domainkey, comprobados por DNS). El binding send_email envía de verdad desde hola@pulso.travel y el correo aterriza en INBOX, no en spam. Límite honesto: solo se puede enviar a direcciones verificadas como destino en Email Routing, hoy solo lc@cooweb.co. Para usuarios reales haría falta un proveedor transaccional. Falta DMARC en el DNS, que mejoraría la entrega.","evidencia":"POST /v1/auth/magic-link → 202 · correo recibido 17/08/2026 de hola@pulso.travel, labelIds UNREAD+INBOX","bloquea":0,"created_at":"2026-08-17 13:59:24"},{"id":"h0authpropia","fase":0,"asunto":"La auth del PoC ya no depende de Zero Trust","resultado":"Sesión propia firmada con HMAC-SHA256 sobre Web Crypto, sin dependencias y sin negociación de algoritmo (así no existe el ataque alg:none de JWT). Los magic links viven en KV como hash, no en claro, se consumen al usarlos y caducan a los 15 minutos. El destino de vuelta se valida para que el enlace del correo no sea un redirector abierto. Zero Trust queda como segundo backend, activable cambiando AUTH_MODE.","evidencia":"84 tests en verde, entre ellos: firma manipulada para subirse a revisor → rechazada; enlace usado dos veces → 401; destino externo → forzado a /obra","bloquea":0,"created_at":"2026-08-17 13:59:24"}],"faseActual":1}