Skip to content

feat(credential): add public /events/[slug]/credencial page - #300

Merged
aalvaropc merged 18 commits into
mainfrom
feature/CredencialPaginaPublica
Jul 29, 2026
Merged

feat(credential): add public /events/[slug]/credencial page#300
aalvaropc merged 18 commits into
mainfrom
feature/CredencialPaginaPublica

Conversation

@aalvaropc

@aalvaropc aalvaropc commented Jul 25, 2026

Copy link
Copy Markdown
Member

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 the credential.enabled flag.
  • Islands under src/components/react/credential/: CredentialPage, CredentialForm, CredentialPreview, AvatarPicker, ShareBar, plus useFontsReady and useDecodedImage.
  • A conditional CTA on the event page.

Stacked on #292, #294, #295 and #296.

Ships dark. getStaticPaths filters on credential.enabled, and no event carries the block in gdg-ica-data yet, so the route does not exist — the build produces the same page count as main. Turning it on is a one-line change in the data repo.

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:

  1. blob: is absent from the CSP. The photo is read with FileReader.readAsDataURL, never URL.createObjectURL. The directive is img-src 'self' data: https: in firebase.json, and the dev server sends no CSP header, so that breakage would only appear in production.
  2. Fonts. useFontsReady blocks the first draw until Geist is loaded. ctx.font falls 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.ready alone is not enough, because a weight the page never renders in the DOM can still be unloaded when the canvas asks for it.
  3. The QR is generated at build time with the qrcode dependency 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 make toDataURL throw 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 signInAnonymouslyIfNeeded before the API. Without it, request() in src/lib/api.ts returns { 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: null and 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 download on a data: 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 ThemeToggle and 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 touches src/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

pnpm test      # 20 RTL tests
pnpm build

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 signInAnonymouslyIfNeeded ordering; a 429 rendered in Spanish; the official CTA carrying target="_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 .astro and its getStaticPaths but 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_PATH at a local data clone with an event carrying credential.enabled: true, run the emulators, and set FIREBASE_EMULATOR_PROJECT to match the project you started them with.

aalvaropc added 15 commits July 25, 2026 08:13
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.
@aalvaropc aalvaropc changed the title feat(credencial): pagina publica /events/[slug]/credencial feat(credential): add public /events/[slug]/credencial page Jul 28, 2026
…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
@aalvaropc
aalvaropc marked this pull request as ready for review July 29, 2026 00:55
@aalvaropc
aalvaropc requested a review from DavidMarioLC as a code owner July 29, 2026 00:55
@aalvaropc
aalvaropc merged commit 64f24ee into main Jul 29, 2026
@aalvaropc
aalvaropc deleted the feature/CredencialPaginaPublica branch July 29, 2026 00:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant