feat(credential): add public /events/[slug]/credencial page - #300
Merged
Conversation
Prepara el terreno para la pagina publica de credenciales anadiendo el
bloque `credential` al esquema de eventos, en los tres puntos por donde
pasa un evento: el loader del repo de datos, el esquema de contenido de
Astro y el esquema de escritura de Cloud Functions.
El bloque es opcional en toda la cadena, no defaulteado: los eventos ya
publicados no tienen la clave, y materializar un default reescribiria su
JSON y afirmaria que la feature existe donde no existe.
Lo critico es el esquema de functions. `eventSchema` es .strict(),
`updateEvent` escribe `{ ...req.body }` tal cual, y `EventForm` carga con
`{ ...EMPTY_EVENT, ...data }` y guarda con `{ ...form }`. Al ser spreads
en runtime, una clave desconocida leida del JSON sobrevive en el estado
del formulario y vuelve a salir en el payload: sin esta entrada, el
primer evento cuyo JSON traiga `credential` quedaria ineditable desde
/admin/events con un 400. Por eso este cambio debe estar desplegado
antes de que el bloque aterrice en gdg-ica-data.
Tambien se declara `credential` en la interfaz `EventData` (opcional y
fuera de EMPTY_EVENT) para que el round-trip sea deliberado y no dependa
del spread, y se corrige CLAUDE.md, que decia Astro 5 cuando el repo
corre 7.1.1.
Piezas puras y testeadas sobre las que se montara el endpoint publico de credenciales. No agrega rutas ni toca ninguna existente: cero cambio de comportamiento en produccion. - schemas/credentials.ts: esquemas .strict() de creacion, cambio de estado de carga a Bevy y moderacion de foto. Los cinco consentimientos son z.literal(true) y no z.boolean(), porque no existe un registro valido sin consentimiento: la casilla sin marcar debe ser un 400 que el cliente pueda mapear a la casilla concreta, nunca un `false` almacenado que parezca una negativa que aceptamos igual. Las imagenes se restringen a data URL de JPEG con topes de longitud calculados contra el limite de 1 MB de express.json. - services/credentialSequence.ts: asignacion de letra de grupo round-robin, no aleatoria, para que los grupos queden equilibrados en cualquier prefijo de la secuencia y no solo al final. Con el set de letras vacio devuelve "A" en vez de lanzar: un evento mal configurado no puede rechazar una inscripcion. - services/credentialSearch.ts: normalizacion y enmascarado de DNI, y tokens de busqueda que delegan en nameMatch.buildSearchTokens con el DNI en la posicion de ticketNumber, para que una credencial y su fila del roster tokenicen por el mismo camino. Eso es lo que hara viable la conciliacion posterior, y hay un test que lo fija. El DNI se normaliza para agrupar conflictos, nunca para rechazarlos: bloquear duplicados dejaria que cualquiera impida inscribirse a una persona real ocupando su numero primero.
Tercera pieza de la credencial compartible del DevFest ICA 2026. Solo la capa de dibujo: ninguna pagina la monta todavia, asi que nada de esto es alcanzable desde el sitio. - renderCredential.ts: CREDENTIAL_LAYOUT con todos los numeros ajustables en un solo objeto, espejando como certificatePdf.ts aisla su LAYOUT. drawCredential es sincrona, no toca DOM mas alla del contexto que recibe, y toma las imagenes ya decodificadas. Eso es lo que la vuelve testeable en jsdom contra un contexto grabador: el repo no trae polyfill de canvas, asi que los tests fijan la secuencia de llamadas y las coordenadas calculadas, no pixeles. Helpers puros aparte: coverRect, fitText, initials, displayName. - exportCanvas.ts: lienzo de export a tamano nativo fijo (lo que el asistente descarga no puede depender de la pantalla que lo genero) y lienzo de preview escalado por devicePixelRatio topado en 2, porque un Android 3x generaria un canvas de 3240 px redibujado en cada tecla. encodeUnderBudget baja calidad por escalones y presupuesta en CARACTERES, que es la unidad que acota el esquema Zod del servidor: medir en bytes haria que una credencial pase aqui y reciba un 400 alla. - mascots.ts + 8 PNG: avatares para quien no sube foto. Dos trampas quedan documentadas en el codigo porque solo se manifiestan en produccion: El usuario de GitHub se renderiza SOLO como texto. Traer avatars.githubusercontent.com al canvas sin una carga CORS-limpia lo contamina, y toDataURL lanza SecurityError al exportar: mataria la feature entera en silencio, y por una decision de infraestructura ajena. El recorte circular del avatar se establece ANTES del drawImage, o la foto se dibuja como un cuadrado sobre toda la tarjeta. Hay un test que fija ese orden. Las mascotas son formas abstractas propias en los cuatro colores de Google, no mascotas de lenguajes: el gopher de Go es CC BY 3.0 y exige atribucion, otras tienen licencias incompatibles y las marcas de Google traen guidelines. Son PNG y no SVG porque drawImage con fuente SVG necesita width/height intrinsecos y se comporta distinto entre navegadores. El arte es placeholder; la geometria si es definitiva, asi que reemplazar los archivos por el arte final no requiere tocar codigo. 50 tests nuevos.
…ientos La politica vigente afirmaba textualmente que el sitio "no recopila, almacena ni comparte datos personales" y que "no existen formularios de contacto, sistemas de registro ni suscripciones en este sitio". La credencial de asistente pide DNI, correo y foto opcional, asi que ambas afirmaciones quedan falsas. No es un parche: es una reescritura. El texto anterior ya se comprometia a actualizarla si se anadian formularios, asi que esto cumple esa promesa antes de que exista el formulario, no despues. Secciones nuevas: que datos se recopilan (enumerados uno a uno), para que se usan, con quien se comparten (Google via Bevy, y Firebase como encargado del tratamiento), plazos de retencion, derechos ARCO bajo la Ley N.o 29733, menores de edad, y seguridad. Dos cosas se dicen con todas sus letras en vez de insinuar lo contrario: La credencial se compone en el dispositivo del asistente, asi que una vez descargada o publicada GDG Ica no puede recuperarla. La moderacion aplica a la copia que almacenamos y a lo que enviamos por correo, y nada mas. Prometer un retiro total seria mentir. Ya no es cierto que no se guarde nada en el navegador: Firebase Auth deja un identificador de sesion anonimo. Es una necesidad tecnica y no analitica, pero omitirlo seria repetir el error que este PR corrige. Nuevo src/lib/consent.ts como unica fuente de verdad, importado tanto por la pagina Astro como (luego) por el formulario React, para que la version mostrada en pantalla y el consentPolicyVersion almacenado no puedan divergir. Un consentimiento que nombra una version cuyo texto ya no podemos producir no prueba nada. Los cinco consentimientos replican los del panel oficial, incluida una declaracion de edad, porque una persona transcribiendo el registro a Bevy no puede aceptar esas condiciones en nombre del asistente. Quedan dos TODO marcados: las URLs exactas de las condiciones de eventos GDG y del codigo de conducta deben confirmarse contra el panel real antes de publicar.
Quinta pieza de la credencial compartible del DevFest ICA 2026. Habilita
Cloud Storage en el proyecto, que hasta ahora no estaba configurado en
absoluto, y expone la primera ruta publica de la feature. Ninguna pagina
la consume todavia.
Modelo de datos: events/{slug}/credentials/{credentialId}, subcoleccion
hermana de roster. ID autogenerado y no el DNI: los duplicados tienen que
poder convivir, y un DNI como ID dejaria que un ocupante sobrescriba a la
persona real.
El correlativo se asigna dentro de la misma transaccion que crea el
documento. Una transaccion es estructuralmente necesaria:
FieldValue.increment() es atomico pero no devuelve el valor resultante, asi
que no puede estamparlo en la credencial, y un contador shardeado no
produce una secuencia sin huecos, que es justo lo que "primeros 100"
necesita.
Las imagenes viajan como data URL base64 por la API en vez de subida
directa a Storage. La subida directa exigiria una coleccion de reservas
para que las reglas tengan contra que hacer firestore.get(), un barrido de
objetos abandonados y un permiso real de escritura para usuarios anonimos,
todo para ahorrar un round trip que el cliente igual gasta reescalando
para el canvas. Consecuencia: storage.rules queda cerrada a toda
escritura de cliente, y ningun principal anonimo toca el bucket.
Nada decodifica la imagen. Se verifican los magic bytes del contenedor y
el buffer va directo al bucket, lo que mantiene toda la clase de CVEs de
parsers de imagen fuera de alcance. El prefijo MIME lo controla el
atacante; los magic bytes son el payload.
consentUserAgent sale del header de la request y nunca del body: un
consentimiento cuya procedencia declara el cliente no prueba nada. El
audit log no lleva DNI, nombre ni correo, porque audit_log lo leen otros
ojos que la coleccion de credenciales.
credentialLimiter va keyed por IP con el mismo comentario que joinLimiter:
los tokens anonimos son gratis de acunar, asi que limitar por UID es
evadible. 8/hora porque el falso positivo realista es un NAT de
universidad. Deliberadamente NO se limita por DNI: eso reintroduciria el
bloqueo que permitir duplicados existe para evitar.
En storage.rules la comprobacion de rol hace exists() antes de get(): sin
eso, un usuario autenticado sin documento en users/{uid} hace que get()
devuelva null y leer .data.role lance. La peticion igual quedaba denegada,
pero por crash de la regla y no por evaluacion, lo que inundaba los logs
del emulador y habria tapado una mala configuracion real.
Tambien se anade storage al --only del deploy y del script test:rules; sin
eso las reglas nuevas nunca se publicarian.
18 tests de handler y 34 de reglas (Firestore y Storage).
NOTA DE DESPLIEGUE: hay que habilitar Cloud Storage en la consola de
Firebase del proyecto appgdgica ANTES de desplegar este PR. Elegir la
ubicacion del bucket es permanente.
Octava y ultima pieza de la credencial compartible del DevFest ICA 2026. Junta el renderizador, el endpoint y los consentimientos en la pagina que ve el asistente. Sale APAGADA: getStaticPaths filtra por credential.enabled y hoy ningun evento tiene el bloque, asi que la ruta no existe todavia. Activarla es un cambio de una linea en gdg-ica-data. El formulario va en DOS PASOS y el orden es la decision de conversion, no una preferencia de maquetado. El paso 1 (nombre, apellido, GitHub, avatar) es enteramente local y no envia nada: el asistente obtiene su imagen compartible ANTES de que le pidamos un DNI. El paso 2 pide lo que el panel oficial necesita transcrito, mas los cinco consentimientos. La pantalla de exito es contundente a proposito. El riesgo central de todo el embudo hibrido es que alguien llene nuestro formulario, nunca toque el panel oficial y llegue el dia del evento creyendo estar inscrito. Eso es peor que no tener formulario, asi que el CTA de inscripcion es el elemento mas grande de la pantalla y el texto no deja lugar a leer la credencial como prueba de nada. El QR del panel oficial se genera en build con la dependencia qrcode que ya se usa en [slug].astro, y se pasa como data URL para que el canvas lo dibuje same-origin: una imagen remota contaminaria el lienzo y haria que toDataURL lance al exportar. Imprimirlo en la tarjeta hace que cada credencial compartida lleve el embudo de vuelta. La foto se lee con FileReader.readAsDataURL y NUNCA con URL.createObjectURL: blob: no esta en la directiva img-src del CSP en firebase.json, y como el dev server no manda cabecera CSP, esa rotura solo se veria en produccion. Hay un comentario en el sitio de llamada. Al reescalar a 512 px se elimina el EXIF, incluida la ubicacion GPS, asi que esos datos nunca salen del dispositivo. useFontsReady bloquea el primer dibujo hasta que Geist este cargada. ctx.font cae a la sans por defecto EN SILENCIO si la face no esta lista, y el bug solo aparece en carga fria: es el defecto visual mas probable de la feature y uno que nadie notaria desarrollando con la cache caliente. document.fonts.ready no basta, porque un peso que la pagina nunca renderiza en el DOM puede seguir sin cargar cuando el canvas lo pide. El submit llama a signInAnonymouslyIfNeeded ANTES de la API. Sin eso, request() devuelve "Not authenticated" y el formulario falla en silencio con un error en ingles dentro de una UI en espanol. Hay un test que fija el orden de las llamadas. Compartir intenta primero la Web Share API, que en movil publica la imagen real y esquiva el CSP, y cae a una descarga. Los enlaces sociales solo pueden compartir la URL de la pagina: todas las plataformas descartan una imagen de lado cliente en un share intent, y decirlo en pantalla es mejor que un boton que publica un enlace cuando el usuario esperaba su tarjeta. Se anade tambien el CTA condicionado en la pagina del evento. 18 tests RTL.
El E2E mostro la fecha recortada como "sabado, 21 de noviembre de 2..." en la credencial generada: fitText la elidia porque a 32px no cabia en 480px. El QR arranca en x=856 y la fecha en x=268, asi que habia espacio de sobra sin usar. A 30px sobre 560px entra una fecha larga en espanol completa. El elidido seguia funcionando como se diseno; el problema era el ancho asignado, no el algoritmo.
Encontrado en el E2E: la credencial almacenada y la que se adjunta al correo mostraban "?" donde va la letra de grupo. La causa es de ordenamiento y no de dibujo. La letra deriva del correlativo, que asigna el servidor dentro de la transaccion, asi que el cliente no puede conocerla hasta que la llamada de creacion responde. Al componer la tarjeta antes de enviar, la copia que se guardaba llevaba siempre el placeholder. Los tests unitarios no podian verlo: cada lado era correcto por separado. Se agrega PATCH /api/events/:slug/credentials/:id/image, publico y sobre el mismo token anonimo que la creacion, para adjuntar la tarjeta una vez conocida la letra. La escritura queda fijada al UID anonimo que creo el registro, asi que un visitante no puede sobrescribir la tarjeta de otro aunque conozca el id del documento. Hay un test para ese caso. Se reusa el mismo limitador por IP que la creacion, y la verificacion de magic bytes: igual que en createCredential, nada decodifica la imagen. 5 tests nuevos.
…rupo Lado cliente del mismo bug que encontro el E2E: la credencial almacenada y la del correo mostraban "?" donde va la letra de grupo. La letra deriva del correlativo que asigna el servidor, asi que componer la tarjeta antes de enviar garantiza que la copia guardada lleve el placeholder. Ahora la creacion manda credentialImageDataUrl en null y la tarjeta se adjunta en una segunda llamada, ya re-renderizada con la letra real. El adjunto no se espera dentro del flujo de UI: el asistente ya tiene su credencial en pantalla, y un fallo al adjuntar no puede convertir una inscripcion exitosa en un mensaje de error. Dos tests nuevos: que la creacion mande la imagen en null, y que el adjunto ocurra despues de la creacion cuando el entorno permite componer el canvas.
Encontrado montando el E2E: el aria-label del canvas se construia con
input.firstName, que lleva "Tu nombre" como placeholder visual antes de
que se escriba nada. Un lector de pantalla anunciaba entonces "Vista
previa de la credencial de Tu nombre", leyendo el relleno como si fuera
el nombre de la persona.
El label pasa a construirlo CredentialPage, que es el unico que sabe si
hay un nombre real o solo el placeholder. Sin nombre dice "Vista previa
de tu credencial".
De paso desaparece la ambiguedad que hacia que getByLabel("Nombre")
resolviera al canvas y al campo a la vez.
2 tests nuevos.
…inaPublica # Conflicts: # firestore.rules # functions/src/index.ts # storage.rules # tests/rules/setup.ts
…inaPublica # Conflicts: # src/lib/__tests__/consent.test.ts # src/lib/consent.ts # src/pages/privacy-policy/index.astro
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
The page the attendee actually sees, joining the renderer, the endpoint and the consent constants.
src/pages/events/[slug]/credencial.astro— prerendered, gated on thecredential.enabledflag.src/components/react/credential/:CredentialPage,CredentialForm,CredentialPreview,AvatarPicker,ShareBar, plususeFontsReadyanduseDecodedImage.Stacked on #292, #294, #295 and #296.
Why
The form is two steps, and the order is the decision.
Step 1 (first name, last name, GitHub, avatar) is entirely local and sends nothing. The attendee gets their shareable image before being asked for a DNI. Step 2 asks for what the official panel needs transcribed, plus the five consents. Asking for an identity document before giving anything back is where people abandon.
The success screen is blunt on purpose. The central risk of the hybrid funnel is someone filling our form, never touching the official panel, and arriving on the day believing they are registered. So the registration CTA is the largest element on the screen, the heading says literally "Todavía no estás inscrito", and the 15-minute timeout is flagged before they start. Tests pin that copy, because it is exactly the kind of wording a well-meaning refactor softens.
Three platform traps, each commented at the call site:
blob:is absent from the CSP. The photo is read withFileReader.readAsDataURL, neverURL.createObjectURL. The directive isimg-src 'self' data: https:infirebase.json, and the dev server sends no CSP header, so that breakage would only appear in production.useFontsReadyblocks the first draw until Geist is loaded.ctx.fontfalls back to the default sans silently when the face is not ready, and the bug only shows on a cold load — the most likely visual defect in the feature, and one nobody would catch developing with a warm cache.document.fonts.readyalone is not enough, because a weight the page never renders in the DOM can still be unloaded when the canvas asks for it.qrcodedependency already used in[slug].astro, and passed as a data URL so the canvas draws it same-origin. A remote image would taint the canvas and maketoDataURLthrow at export. Printing it on the card is what makes every shared credential carry the funnel back to the panel that actually registers people.The most likely integration bug, with a test. Submit calls
signInAnonymouslyIfNeededbefore the API. Without it,request()insrc/lib/api.tsreturns{ success: false, error: "Not authenticated" }and the form fails silently with an English string in a Spanish UI. A test asserts the call order over a shared log, not merely that both happened.The card is attached after the response. The group letter comes from a server-assigned sequence number, so a card composed before submitting would be stored and emailed with a placeholder. Create sends
credentialImageDataUrl: nulland the card is attached in a second call. The attach is not awaited into the UI path — the attendee already has their credential on screen, and a failed attach must not turn a successful registration into an error.Privacy. Downscaling the photo to 512px strips EXIF, GPS included, so that never leaves the device. The picker opens on mascots and the photo is a deliberate opt-in, which also keeps the moderation queue short.
Sharing tries the Web Share API first — on mobile it posts the actual image and sidesteps the CSP — then falls back to a download, because some mobile browsers ignore
downloadon adata:URL. Social links can only ever share the page URL, since every platform strips a client-side image out of a share intent, and saying so on screen beats a button that quietly posts a link when the user expected a card.Known follow-up: dark mode
This page is built light-only, and the code says so at the call sites. That was correct when it was written —
dark:variants existed in the codebase but nothing could trigger them, since no public page wired a theme.PR #278 changes that: it adds a
ThemeToggleand a design system across the public site. Once it lands, this page becomes the only public one that ignores the theme, and it will need dark variants on the form, the preview frame and the surrounding chrome. It also touchessrc/pages/events/[slug].astro, where this PR adds the CTA, so expect a small conflict there.The card itself must stay light regardless. It is an image people post to social networks, not a surface of the site, so the canvas renderer is out of scope for that work — only the frame around it.
Merging this first was a deliberate call: #278 is still in progress, and holding a finished feature for an unfinished one costs more than the follow-up it creates.
How to test
Covered: step 1 asks for nothing sensitive; the button enables only with a name; an invalid GitHub username is flagged inline; submission is blocked until all five consents are checked and the error names which one is missing; the
signInAnonymouslyIfNeededordering; a 429 rendered in Spanish; the official CTA carryingtarget="_blank" rel="noopener noreferrer"; and the canvas not announcing placeholder text as the attendee's name.Known limit of this verification: because the route produces zero pages until the flag is on, the build validates the
.astroand itsgetStaticPathsbut does not bundle the island. Component coverage comes from tests and the linter, not the build. Review the page in a browser once the flag is flipped, before promoting it.To exercise it end to end, point
GDG_DATA_LOCAL_PATHat a local data clone with an event carryingcredential.enabled: true, run the emulators, and setFIREBASE_EMULATOR_PROJECTto match the project you started them with.