512kg / dima
Comercial

Vendo arte

Vendo cuadros originales y láminas en tres tiendas que he montado yo. La galería de originales es la que tiene el problema difícil: cada obra existe una sola vez, así que cada error es un cuadro vendido dos veces o ninguna.

La galería

Un Worker de Cloudflare con recursos estáticos. No hay base de datos, ni CMS, ni paso de compilación, ni nada que instalar: el catálogo es un fichero y el Worker lo lee.

El pago va por Stripe. Antes de crear la sesión, un Durable Object reserva todos los cuadros del pedido, todos o ninguno, durante siete minutos; un segundo comprador recibe un 409 en vez de una página de pago. La historia completa de ese candado.

El precio nunca viene del navegador

La petición de pago lleva tres cosas: qué cuadros, el país y un código de descuento opcional.

{ "ids": ["<slug>", "<slug>"], "country": "US", "code": "" }

Ni precio, ni título, ni gastos de envío. Todo eso lo busca el Worker en el mismo fichero de catálogo que cargó el navegador del visitante. Cambiar un precio en ese fichero y publicar cambia de una vez el precio que se enseña y el que se cobra. Un cliente manipulado puede cambiar qué cuadro compra, no cuánto cuesta.

Un carrito que llevaba tres semanas ahí

Alguien mete un cuadro en el carrito y lo deja. Tres semanas después lo compra otra persona. La primera vuelve, y su navegador conserva un catálogo en el que el cuadro sigue disponible.

Aquí no se puede confiar en nada del lado del cliente, así que la respuesta tiene tres partes:

  1. El webhook marca la venta al momento. Cuando el pago se completa, la marca queda escrita en segundos. No espera a que se edite el catálogo y se vuelva a publicar.
  2. El carrito dice la verdad al abrirse. Cada vez que se abre el panel pregunta al servidor cuáles de sus artículos siguen disponibles. La llamada se hace después de pintar el panel y no se espera, así que abrir el carrito nunca depende de la red. Si falla, no se rompe nada.
  3. El servidor se niega igualmente. El pago vuelve a comprobar cada artículo y responde 409 con la lista de los ya vendidos. Una página antigua, una segunda pestaña o una petición escrita a mano no pueden saltárselo, porque la petición solo lleva identificadores.

El cliente tacha exactamente esas filas y deja pagar el resto. Un carrito de seis con dos vendidos no falla entero.

Dos reglas que no relajaría: un identificador repetido en un pedido se reduce a uno, así que un cuadro único nunca se cobra dos veces; y un código de descuento inválido rechaza el pedido en vez de cobrar el precio completo sin avisar.

Enseñar la pintura sin entregar el cuadro

Un pintor quiere que el visitante vea la pincelada de verdad. Un pintor no quiere que un fichero con calidad de impresión llegue al navegador de nadie.

El original no sale de mi máquina; no tiene dirección, ni siquiera privada. Un script lo corta en una rejilla fija de celdas de unos 540 píxeles y escribe solo los cuadros «blancos» de un tablero de ajedrez. Los «negros» no existen en ningún sitio en alta resolución, así que ninguna dirección, tamaño o parámetro puede producirlos.

Fig. 1 — La mitad de las celdas no existe. Una rejilla esquemática, más pequeña que una real. Las celdas escritas en alta resolución son aquellas en las que fila más columna es par. Las demás nunca se generaron, así que pedir una devuelve 404 sea cual sea el tamaño o el parámetro.

Esa última parte es la clave. A otra web le rompieron su vista previa en ajedrez pidiendo las teselas a varios tamaños y superponiéndolas. Aquí hay un solo tamaño, una rejilla fija y ningún redimensionado en el servidor. Lo máximo que alguien puede sacar es la foto normal y la mitad de las celdas.

La alternativa evidente era guardar el original en un almacén y que un Worker lo redimensionara a petición. Funciona, pero el original estaría en la nube, y un solo fallo en la lista de tamaños permitidos lo serviría entero. Con teselas generadas de antemano, ese fallo no puede ocurrir. Prefiero las protecciones que aguantan porque la cosa no está, a las que aguantan porque una regla está bien escrita.

Un enlace en un correo no debe hacer nada

Se puede hacer una oferta por un cuadro. No hay cuentas: el comprador es una dirección de correo y una página privada con un identificador de 20 caracteres.

  • Por debajo de un mínimo, el servidor dice que no sin revelar la cifra.
  • Aceptar una oferta no reserva el cuadro. Sigue a la venta por su precio y se lo lleva quien pague primero; las demás ofertas abiertas sobre él se cierran y se avisa a quienes las hicieron.
  • Los enlaces de los correos solo enseñan una página. Toda acción es el envío de un formulario. Los analizadores de correo abren los enlaces, y una oferta no la debe aceptar un antivirus.

Decisiones más pequeñas

  • La firma del webhook de Stripe se verifica a mano con WebCrypto: una ventana de 300 segundos contra repeticiones, el cuerpo tal como llega y no el JSON vuelto a serializar, y una comparación en tiempo constante.
  • Las tipografías las sirve la propia web. Cargarlas desde Google mandaba allí la IP de cada visitante antes de que hubiera consentido nada.
  • El catálogo, las reseñas y el blog se generan en el servidor. Los rastreadores de IA no ejecutan JavaScript, y lo que no pueden leer no existe para ellos.
  • Las rutas de pago admiten 8 peticiones por minuto, y el resto de la API, 30.
  • Las compras se notifican a GA4 desde el servidor, con una marca por sesión para que un reintento de Stripe no cuente como una segunda venta.

Otras dos tiendas

Una la genera entera un Worker a partir de un único fichero de catálogo.

La otra es una aplicación Next.js 16, generada de forma estática y desplegada en Workers con OpenNext, con Server Components por defecto. Dos detalles que me gustan de ella:

  • Filtrar y ordenar sin datos en el cliente. La barra del catálogo cambia dos atributos del contenedor. display: none quita lo que no encaja con el filtro, y order, con la posición de cada celda calculada en el servidor como variable CSS, ordena por precio. La página sigue siendo estática y el catálogo nunca entra en el bundle de cliente.
  • Ni una media query para el dibujo del producto. Se dimensiona en unidades de contenedor (cqw), así que mantiene su proporción igual en una celda de la rejilla que en la ficha grande.
  • JavaScript72.3%
  • HTML15.3%
  • CSS6.0%
  • TypeScript5.8%
  • Otros0.6%