Ir al contenido

Endpoints API

Backend: tcgcards-api corriendo en Cloud Run. Todos los endpoints REST. Prefix: /api/v1.

URL prod: https://tcgcards-api-1033181994095.us-central1.run.app

Hay 4 tipos de auth distintos según el endpoint:

TipoCómo se envíaQuién la usa
noneEndpoints públicos (catálogo, feeds)
user JWTAuthorization: Bearer <jwt>Login con Google → JWT propio firmado HS256
adminuser JWT + user.isAdmin === trueSolo admins del marketplace
admin tokenX-Admin-Token: <token>Endpoints de catálogo sync — usa ADMIN_TOKEN env var
internal authX-Internal-Auth: <secret>Server-to-server (Next.js → API). Usa INTERNAL_AUTH_SECRET
internal cronX-Internal-Cron-Secret: <secret>Solo Cloud Scheduler. Usa INTERNAL_CRON_SECRET
MétodoPathAuthDescripción
GET/api/v1/healthnoneHealthcheck (devuelve status de conexión Mongo)
GET/api/v1/tcgsnoneLista TCGs habilitados
GET/api/v1/tcgs/:slugnoneTCG específico
GET/api/v1/tcgs/:slug/raritiesnoneRarezas disponibles
GET/api/v1/setsnoneLista paginada ?tcg=. No devuelve sets sin cartas (ver abajo)
GET/api/v1/sets/recentnoneSección «Nuevos lanzamientos» de la portada, resuelta: { released, upcoming } (ver abajo)
GET/api/v1/sets/:idnoneSet específico. 404 si el set no tiene cartas (ver abajo)
GET/api/v1/cardsnoneBusca/filtra cards con facetas
GET/api/v1/cards/suggestnoneAutocomplete
GET/api/v1/cards/countsnoneConteo de matches por TCG para ?q= (1 consulta)
GET/api/v1/cards/:idnoneCard específica
GET/api/v1/cards/:id/listingsnoneListings activos de esa card
GET/api/v1/cards/:id/recent-salesnoneÚltimas 5 ventas
GET/api/v1/sitemap/cardsnoneCards para sitemap XML
GET/api/v1/sealed-products/setsnoneSets que tienen sellados de ese ?tcg=. Primer paso del selector al publicar (ver abajo)
GET/api/v1/sealed-productsnoneSellados del catálogo: ?tcg=&set=&q=&page=&limit= (máx 50)
GET/api/v1/sealed-products/:idnoneUn sellado. 404 con el id de una carta
GET/api/v1/sealed-products/:id/listingsnoneVendedores que lo tienen
GET/api/v1/sitemap/usersnoneUsernames públicos para sitemap

GET /api/v1/cards (y los feeds de listings tipo card, ej. /api/v1/listings/cards) aceptan filtros de atributo declarativos según el TCG, además de los conocidos (tcg, set, rarity, q, inStock, page, limit). Cada TCG define sus propios filtros, label y path en el registro src/services/tcgFilters.ts (ej. Pokémon: energyType, stage; One Piece: color, cardType).

  • Query: cada filtro es su propio parámetro con valores separados por coma → OR dentro del mismo filtro, AND entre filtros distintos. Ej: ?tcg=pokemon&energyType=Fire,Water&stage=Basic.
  • Solo keys registradas para ese TCG se aplican (las demás se ignoran). El backend mapea cada key a su path en attributes.raw.* y arma el $match ($in para arrays/escalares; los multi-valor con splitOn usan regex).
  • Respuesta facets.attributes: junto a sets y rarities, /cards devuelve facets.attributes: [{ key, label, order, values: [{ value, count }] }]. Sigue Modelo B (auto-exclusión): el conteo de cada filtro ignora su propia selección pero respeta los otros, para poder sumar valores sin que el conteo caiga a 0.
  • Consistencia con búsqueda: cuando hay q, las facetas se calculan vía Atlas Search ($search como stage líder, mismo índice card-search que los resultados), con fallback a regex solo si Atlas no está disponible (tests/CI). Así el conteo de una faceta coincide con lo que devuelve al hacer click.
  • q matchea nombre O número (jun 2026): además del nombre, q busca por número de carta (ej. 97, 97/101, OP09-095) vía el campo numberSearch (ver DB). Una query con dígitos se rutea al path indexado (regex de nombre + prefijo de número), NO a Atlas; y el conteo por TCG (/cards/counts, tabs) usa ese mismo $or (countByTcgNameOrNumber) → el número del tab coincide exacto con los resultados (sin double-count). Aplica también a searchSellerActiveListings (mis publicaciones + perfiles públicos) y al feed /listings/cards. El escáner NO usa este path (identifica por número aparte). Backfill: scripts/backfill-number-search.ts.
  • inStock es dinámico: con inStock=true, TODAS las facetas (sets, rarezas, atributos) cuentan solo cartas con listings activos. No es una faceta sino un filtro binario, así que no se auto-excluye.

Las facetas se memoizan en proceso (TTL corto, sin Redis; inStock es parte de la clave) y se apoyan en ~19 índices parciales {tcg:1, <path>:1} sobre cards (ver base de datos).

GET /api/v1/cards/counts?q=<término> devuelve, en una sola consulta de agregación, cuántas cartas hacen match por cada TCG: { data: [{ tcg, count }] } (solo TCGs con al menos un match). Lo usa la búsqueda global del web (cuando no hay tcg en la URL) para elegir directo el TCG con más resultados y para mostrar el conteo junto a cada tab — antes lanzaba 9 búsquedas completas en paralelo.

  • Usa Atlas Search ($search sobre el índice card-search, mismas cláusulas de autocomplete que /cards) + $group por tcg, con fallback a $regex sobre baseName si Atlas no está disponible.
  • q vacío → []. Cualquier error degrada a regex y, en último caso, a []: nunca rompe la página.
  • Es solo lectura y no afecta la query de resultados de /cards (stock, orden, facetas).

Rendimiento del stock (2026-06-10): el stock de una carta NO está denormalizado; vive en listings. La búsqueda resuelve los cardIds con listings activos en UNA consulta a listings (lista chica) y la usa para el orden “con stock primero” ($in en memoria), el filtro inStock y las facetas; el $lookup de minPrice/listingsCount corre solo para la página visible (≤ 24 cartas), no para todo el catálogo. (Antes era un $lookup por carta matcheada: navegar Magic —111k cartas— tardaba ~14s.) Si algún día hay decenas de miles de cartas distintas publicadas a la vez, la mejora de fondo es denormalizar listingsCount en cards.

Hasta agosto de 2026 un sellado solo se podía publicar como personalizado, con el título escrito a mano: nada se agrupaba, los títulos divergían, no había imagen de catálogo y el buscador no los encontraba.

Ahora el catálogo de TCGplayer también trae sellados —booster boxes, elite trainer boxes, mazos de inicio— y el vendedor los elige de una lista.

cards gana un discriminador productType: 'card' | 'sealed'. Es el mismo patrón que ya usa listings con su kind para guardar cinco tipos de publicación en una colección.

Así se hereda todo lo que ya funciona: la sincronización, el resumen de stock denormalizado, el reconciliador nocturno, el buscador y el selector. Incluir sellados en la búsqueda es quitar un filtro, no construir un camino nuevo.

Son ~6.128 productos en los 4 TCG de TCGplayer (Pokémon 2.894 · Magic 2.779 · One Piece 403 · Riftbound 52), un +3,6% sobre las cartas. Mitos y Leyendas no tiene sellados en su fuente y queda fuera.

TCGplayer entrega marketPrice, medianPrice y lowestPrice para cada sellado, pero corresponden al producto en inglés y en el mercado estadounidense. En Chile el mismo producto en español se vende bastante más barato: mostrar esa referencia confundiría justo al decidir la compra. Es el mismo criterio con el que se oculta para Mitos.

Tampoco lleva number, numberSearch, rarity ni attributes.

Sección titulada «El idioma vive en la publicación, no en el catálogo»

Se revisaron 600 nombres de sellados de Pokémon y ninguno declara idioma. El «Mini Tin [Flareon] español» y el inglés son el mismo producto de catálogo; lo que los distingue lo pone el vendedor. Igual que las cartas.

Una publicación de sellado apunta al catálogo (cardId) o trae título propio (sealedTitle). El sistema los distingue mirando la publicación, sin banderas que se desincronicen.

Un sellado de catálogo no exige fotos —la imagen la pone el producto, igual que en una carta—; uno personalizado sigue exigiendo al menos una, porque sin foto no habría nada que mostrar.

POST /listings/batch no acepta sellados: su esquema fija kind: 'card'.

MétodoPathAuthDescripción
GET/api/v1/listingsnoneBúsqueda general (admin-grade)
GET/api/v1/listings/cardsnoneFeed público de listings tipo card. Filtros: q, tcg, rarity (rareza de la carta de catálogo; excluye publicaciones custom), condition, language (jul 2026: semántica card-level — cartas con ≥1 publicación activa en ese idioma, vía cards.languages denormalizado; los customs se filtran por su propio language; el minPrice mostrado sigue siendo el global de la carta; con condition va por el camino via-listings con precio exacto del subconjunto), sort, offset, limit + filtros de atributo por TCG. Los atributos solo aplican con tcg; un language/rarity desconocido devuelve 200 con 0 resultados
GET/api/v1/listings/cards/facetsnoneFacetas tcgs, rarities, languages y attributes. Acepta q/tcg/condition/rarity/language (+ atributos) con Modelo B (cada faceta aplica todos los filtros menos el propio). attributes, rarities y languages se calculan solo con tcg (jul 2026: sin tcg → [] y las ramas ni se ejecutan — antes rarities mezclaba rarezas de todos los TCG); la faceta tcgs NO aplica atributos. languages = [{value, count}] con valores del enum de idiomas (‘English’…), cuenta cartas únicas del campo denormalizado cards.languages; los customs no aportan a los conteos (sí al feed). Gap conocido (fast-follow): con condition presente las facetas van por el camino via-listings, que no gatea rarities por tcg ni calcula/aplica languages. Conteos = cartas únicas con orden estable; total es global
GET/api/v1/listings/bulknoneFeed público de challa
GET/api/v1/listings/bulk/facetsnoneFacetas challa
GET/api/v1/listings/sealednoneFeed público de sellado
GET/api/v1/listings/sealed/facetsnoneFacetas sellado
GET/api/v1/accessoriesnoneFeed de accesorios
GET/api/v1/accessories/facetsnoneFacetas accesorios
GET/api/v1/listings/decksnoneFeed público de mazos
GET/api/v1/listings/decks/facetsnoneFacetas decks (counts por TCG)
GET/api/v1/listings/customnoneCustom listings (legacy)
GET/api/v1/listings/:id/publicnoneListing público
GET/api/v1/listings/:idnoneListing detallado
POST/api/v1/listingsuser JWT + activeCrea listing
POST/api/v1/listings/batchuser JWT + activePublicación masiva (Publicador Pro): crea N publicaciones card en un request, resultado por carta (éxito parcial), idempotente por clientRef
PATCH/api/v1/listings/:iduser JWT + activeActualiza listing propio
DELETE/api/v1/listings/:iduser JWTElimina listing propio
GET/api/v1/users/me/listingsuser JWTMis listings con filtros
GET/api/v1/users/me/listings/facetsuser JWTFacetas de mis listings (incluye autoPriceEligibleCount = nº de cartas activas de TCGplayer, lo que actualiza “activar en todas”)
POST/api/v1/users/me/listings/bulkuser JWTAcción bulk: pause/activate/mark_sold/delete
POST/api/v1/listings/:id/auto-priceuser JWT + activeActiva/desactiva Precios dinámicos en una carta (body { enabled }; solo cartas de catálogo de TCGplayer elegibles). Rate-limited 60/min
POST/api/v1/me/listings/auto-price/apply-alluser JWT + activeActiva/desactiva Precios dinámicos en todas las cartas elegibles del vendedor (body { enabled }). Rate-limited 10/min
POST/api/v1/me/listings/auto-price/recalcuser JWT + activeRecalcula los precios ahora (todas las del vendedor, o solo una si se pasa listingId). Devuelve los precios aplicados + considered/noFreshPrice/belowMin. Rate-limited 10/min
GET/api/v1/listings/auto-price/last-refreshnoneTimestamp del último refresco de precios de TCGplayer
GET/api/v1/users/:username/listingsnoneListings públicos de un seller
GET/api/v1/users/:username/listings/facetsnoneFacetas del seller

GET /users/me/listings incluye card.marketPrices (precio de referencia de TCGplayer, USD) por publicación de carta — lo usa el front para mostrarle al vendedor la referencia junto a su precio. Los endpoints de cartas (/cards, /cards/:id, /cards/suggest) ya exponían marketPrices.

  • GET /api/v1/listings/decks — feed público de mazos. Query params:

    • q (string, opc): búsqueda case-insensitive en deckTitle.
    • tcg (string slug, opc): filtra por TCG.
    • sort (recent | price-asc | price-desc, default recent).
    • offset (int, default 0), limit (int, default 24, max 48).
    • Respuesta: { data, total, nextOffset }.
  • GET /api/v1/listings/decks/facets — counts por TCG sobre el universo de decks activos con stock. Respuesta: { total, tcgs: [{ slug, count }] }.

  • POST /api/v1/listings con kind: 'deck' — crear un mazo. Body:

    • deckTitle (5-80 chars), tcg (slug), deckCardCount (1-500), photos (1-5 URL Cloudinary), description (10-500 chars), price, quantity, location.
  • PATCH /api/v1/listings/:id — admite campos deckTitle y deckCardCount además de los comunes.

  • POST /api/v1/listings con kind: 'card' — acepta photos (0-2 URLs Cloudinary) opcionales: fotos propias del vendedor de su carta real (frente/dorso), además de los campos de carta. La imagen del catálogo no cambia; estas son adicionales. El PATCH también las admite (cap 2).

Feature opt-in por vendedor: una vez activado, sus cartas de catálogo de TCGplayer se repricen solas cada día. El precio se calcula así:

precio_CLP = round100(card.marketPrices.market[USD] × usdRate_del_vendedor)

  • usdRate es el tipo de cambio (CLP por USD) que define el vendedor en su configuración maestra.
  • Redondeo: half-up al múltiplo de $100 más cercano (aritmética entera).
  • Mínimo de publicación: $50. Una carta cuyo precio calculado quede por debajo no se actualiza (belowMin).

Qué cartas aplican. Solo cartas de catálogo de TCGplayer (card.source === "tcgplayer"). Nunca Mitos y Leyendas (source api.myl.cl), sellado, accesorios ni custom.

Gate de frescura (26h). Para repricar una carta, tanto el set (sets.pricesRefreshedAt) como la carta (cards.marketPrices.pricesUpdatedAt) deben tener precio fresco (≤ 26h). Si no, esa carta se salta (no_fresh_price).

Configuración maestra del vendedor. PATCH /users/me acepta autoPricing: { enabled, usdRate } (usdRate int 1..100000 o null). Al guardar enabled: false, la API apaga el flag en todas las cartas del vendedor en el mismo request (server-side), así no quedan publicaciones reprecándose por su cuenta.

Activación por carta o masiva. POST /listings/:id/auto-price (una carta) y POST /me/listings/auto-price/apply-all (todas las elegibles) prenden/apagan el flag. POST /me/listings/auto-price/recalc recalcula los precios al instante (sin esperar al job diario) y devuelve los precios aplicados junto con los contadores considered / noFreshPrice / belowMin. La faceta autoPriceEligibleCount (en /users/me/listings/facets) indica cuántas cartas activas de TCGplayer hay (lo que “activar en todas” alcanzaría).

Seguridad de la escritura. El reprecio es una escritura quirúrgica con guard CAS { status: active, autoPrice: true } y no toca el flujo de pago: las órdenes congelan el precio al crearse, así que un reprecio nunca altera una compra en curso. (Internamente el código se llama autoPrice / autoPricing; la marca visible es “Precios dinámicos”.)

MétodoPathAuthDescripción
GET/api/v1/me/cartuser JWTMi carrito enriquecido
POST/api/v1/me/cart/itemsuser JWT + activeAgrega item
PATCH/api/v1/me/cart/items/:listingIduser JWT + activeActualiza cantidad (0 = elimina)
DELETE/api/v1/me/cart/items/:listingIduser JWT + activeElimina item
DELETE/api/v1/me/cartuser JWT + activeVacía carrito
POST/api/v1/me/cart/checkoutuser JWT + activeCheckout del carrito (multi-vendedor)
POST/api/v1/me/checkout/confirm-redirectuser JWTConfirma un pago desde el redirect de MP (página de confirmación). Body { paymentId }

El carrito acepta items de varios vendedores. POST /me/cart/checkout lee el carrito autoritativo del servidor, agrupa por vendedor y bifurca:

  • 1 vendedorOrderService.createOrderWithPayment (flujo individual de siempre, reembolso a tarjeta vía MP) → responde { kind: 'order', orderId, mpInitPoint }.
  • 2+ vendedoresCheckoutService.createCartCheckout: crea una orden por vendedor + un agregado checkout, y un solo pago combinado en MP (top-up al wallet del comprador del que se fondea cada orden) → responde { kind: 'checkout', checkoutId, mpInitPoint }.

Mínimo de $1.500 por vendedor (no del total): si la parte de algún vendedor no lo alcanza, el checkout se rechaza nombrando al vendedor. El reembolso de una sub-orden grupal siempre va al wallet (ver decisiones → Pagos).

Elección de entrega por vendedor (2026-07-19): POST /me/cart/checkout acepta deliveries: [{sellerId, method, pickupPointId?}] (máx 50 vendedores; methodshipping/in_person/no_preference/pickup). Es opcional — sin el campo (o sin choice para un vendedor puntual), aplica el fallback legacy: deliveryPreference global del body y luego el del perfil del comprador, sin validar nada (retrocompat exacta con clientes anteriores al feature). Con una elección explícita el server valida por vendedor: el método debe estar entre los que ese vendedor realmente ofrece (ver Métodos de entrega), pickup exige un punto activo propio del vendedor, y shipping exige que el comprador tenga completos rut/teléfono/calle/comuna/región. Cualquier fallo → 400 con mensaje en español nombrando al vendedor; si un grupo es inválido, no se crea nada (ni esa orden ni las demás).

Confirmación post-pago (un solo camino para individual y combinado): todos los pagos de MP redirigen (back_urls) a la página /me/compras/confirmacion. Esa página toma el payment_id que MP agrega a la URL y llama a POST /me/checkout/confirm-redirect, que: verifica el pago contra MP (getPaymentno confía en los params de la URL, que son falsificables), valida que la orden/checkout sea del usuario (403 si no), confirma de forma idempotente reusando PaymentService/CheckoutService (mismo CAS que el webhook → si llegan ambos, el segundo es no-op) y devuelve el resumen. Así la confirmación no depende de esperar el webhook; el webhook queda como respaldo para cuando el comprador cierra la pestaña antes del redirect.

MétodoPathAuthDescripción
GET/api/v1/me/deliveryuser JWTMi configuración de entrega (config efectiva)
PUT/api/v1/me/deliveryuser JWT + activeActualiza métodos + puntos de retiro

Config del vendedor: {shipping, inPerson, coordinate, pickup, pickupPoints: [{id, title, region, commune, address, instructions, active}]} (instructions nullable) — 3 métodos clásicos (envío a domicilio, presencial, “coordinar con el vendedor”) más puntos de retiro propios del vendedor, máx 5.

GET siempre devuelve la config efectiva: si el vendedor nunca guardó nada (sin subdocumento sellerDelivery), responde los defaults — los 3 métodos clásicos activos, pickup apagado, sin puntos.

PUT valida: ≥1 método activo (pickup cuenta solo si tiene ≥1 punto activo — el toggle solo, sin puntos, no habilita nada), máx 5 puntos, y topes de longitud por campo: título 60 / región 60 / comuna 60 / dirección 160 / instrucciones 300. Cualquier violación → 400 con mensaje en español.

Ids de puntos de retiro: un punto nuevo se manda sin id — el server genera uno (pp- + 12 chars aleatorios, crypto.randomBytes). Un punto existente se manda con su id para conservarlo (carritos/checkouts en curso lo referencian). Un id inventado (no pertenece a la config actual del vendedor) o repetido dentro del mismo request → 400.

Dónde se usa esta config:

  • POST /me/cart/checkout (ver Carrito) acepta deliveries[] con la elección de método por vendedor, incluyendo pickup + pickupPointId.
  • El perfil público (GET /users/:username, GET /users/by-id/:id) y cada grupo del carrito enriquecido exponen delivery: {methods, pickupPoints} — proyección pública derivada de una única función (toPublicSellerDelivery()): solo los métodos realmente ofrecibles y solo puntos activos ({id, title, commune, region}); address/instructions nunca se exponen ahí — son privados hasta la compra.
  • PATCH /orders/:id/ship (ver Órdenes) acepta deliveryMethod: 'pickup'.

Correos: las plantillas de orden tienen variante para retiro. newOrder (aviso al vendedor) y orderAccepted (aviso al comprador) mencionan el título del punto (“retirarás tu pedido en tu punto de retiro «X»”). orderShipped para pickup cambia el asunto (“Tu pedido está listo para retirar”) e incluye la dirección completa del punto — el destinatario ya compró, así que ya tiene derecho a verla. Los recordatorios pre-despacho del cron (accept/deliver1/deliver2, ver crons) también nombran el punto al vendedor por su título.

MétodoPathAuthDescripción
POST/api/v1/orders/checkoutuser JWT + activeCrea orden + MP preference
GET/api/v1/orders/:iduser JWTDetalle (buyer o seller)
POST/api/v1/orders/:id/resume-paymentuser JWT + activeReanuda pago si quedó en awaiting_payment
PATCH/api/v1/orders/:id/acceptuser JWT (seller)Acepta
PATCH/api/v1/orders/:id/shipuser JWT (seller)Marca como enviado/entregado. deliveryMethodshipping/in_person/pickup
PATCH/api/v1/orders/:id/completeuser JWT (buyer)Confirma recepción
PATCH/api/v1/orders/:id/canceluser JWTCancela. Comprador solo en pending; en accepted solo el vendedor (reembolsa al comprador). Bloqueado en shipped/completed y si hay disputa abierta.
GET/api/v1/orders/:id/whatsappuser JWTDevuelve WhatsApp de la contraparte si activó esa preferencia
GET/api/v1/me/orders/buyeruser JWTMis compras (cada orden incluye counterpart = vendedor: {id, username, name, avatarUrl})
GET/api/v1/me/orders/selleruser JWTMis ventas (cada orden incluye counterpart = comprador: {id, username, name, avatarUrl})
GET/api/v1/me/orders/countsuser JWTConteos de compras y ventas en una sola llamada, sin traer órdenes

Conteos de órdenes (2026-07-25): /me/orders/buyer y /me/orders/seller ya devolvían counts: { active, completed, canceled } junto a la página de resultados. Ahora ese objeto trae además byStatus, el desglose crudo por estado ({ pending: 2, accepted: 1, … }; las claves ausentes valen 0). Es aditivo: los tres cubos conservan exactamente su valor y su significado.

GET /me/orders/counts devuelve lo mismo para ambos roles en una llamada, sin paginar ni hidratar órdenes — lo usa el bloque de actividad del Resumen del perfil:

{ "data": {
"buyer": { "active": 2, "completed": 1, "canceled": 0, "byStatus": { "pending": 1, "accepted": 1, "completed": 1 } },
"seller": { "active": 1, "completed": 0, "canceled": 1, "byStatus": { "pending": 1, "canceled": 1 } }
} }

Despacho a punto de retiro (2026-07-19): en PATCH /orders/:id/ship, deliveryMethod: 'pickup' exige que la orden tenga un pickupPoint asociado (snapshot tomado en el checkout) — si no, 400. A diferencia de shipping (que sigue exigiendo proofImage, una URL de Cloudinary) e in_person (que lo sigue prohibiendo), en pickup el proofImage es opcional: el vendedor puede o no dejar constancia de que dejó el pedido en el punto.

Privacidad del punto de retiro: la respuesta de una orden (GET /orders/:id, /me/orders/buyer, /me/orders/seller) redacta pickupPoint a null mientras la orden esté awaiting_payment o cancelada sin pago (timeout/huérfana/rechazo de MP) — la dirección exacta solo se muestra tras la compra. El dominio conserva el snapshot igual (lo necesitan ship() y los correos), solo se oculta en la respuesta al usuario.

MétodoPathAuthDescripción
GET/api/v1/me/unread-countuser JWT# de chats sin leer
GET/api/v1/me/conversationsuser JWTMis conversaciones recientes
GET/api/v1/conversations/:orderId/messagesuser JWTMensajes de la orden
POST/api/v1/conversations/:orderId/messagesuser JWTEnvía mensaje (rate-limited)
MétodoPathAuthDescripción
POST/api/v1/orders/:orderId/reviewuser JWTDeja review (después de completar)
GET/api/v1/orders/:orderId/reviewuser JWTMi review en esa orden
GET/api/v1/users/:username/reviewsnoneReviews recibidas por un seller
MétodoPathAuthDescripción
POST/api/v1/orders/:id/disputeuser JWTAbre dispute (buyer o seller)
GET/api/v1/orders/:id/disputeuser JWTDetalle dispute
POST/api/v1/orders/:id/dispute/responseuser JWTResponde dispute (la otra parte)
GET/api/v1/admin/disputesadminCola de disputes
GET/api/v1/admin/disputes/:idadminDetalle admin
POST/api/v1/admin/disputes/:id/resolveadminResuelve (outcome + adminNote)
MétodoPathAuthDescripción
POST/api/v1/reportsuser JWTReporta listing o user
GET/api/v1/admin/reportsadminLista agrupada
GET/api/v1/admin/reports/:idadminDetalle
MétodoPathAuthDescripción
POST/api/v1/uploads/signuser JWTFirma URL para subir a Cloudinary. kind: listing / proof / avatar / cover / dispute / banner / email (solo admin) / dispute_response

kind: email (imágenes dentro de una campaña) usa un publicId único, a diferencia de avatar o cover, que se pisan a propósito. Los correos ya enviados apuntan a esas URLs para siempre: reutilizar el id cambiaría la imagen de un correo que alguien recibió hace meses.

POST /api/v1/me/share-card genera una imagen de 1080×1920 (formato historia) con el perfil del vendedor, para compartir en redes o mostrarla en un torneo. Se arma con cartas de su propio inventario, y lleva un código QR y la dirección de su perfil.

RespuestaCuerpo
200{ url, restantes, conCartasPropias }
401sin sesión
429{ error: { code: 'SIN_CUPO', message } }

Solo el dueño genera la suya. El endpoint no recibe cuerpo: el vendedor sale del token, nunca del cliente.

La dirección cambia en cada generación. El objeto en R2 es siempre <carpeta>/<username>.jpg —se reemplaza, no se acumula historial— pero la URL devuelta lleva una marca de versión al final (?v=...). Sin eso el navegador serviría la ficha anterior desde su caché y el botón de regenerar parecería no hacer nada; las vistas previas de WhatsApp e Instagram son peores todavía, porque cachean por su cuenta e ignoran el Cache-Control. El front no debe recortar esa marca. Y como esa marca identifica la versión, el objeto se cachea un año.

Sale en JPEG y no en PNG: es un collage fotográfico, el PNG no aporta nada —no hay transparencia— y pesa diez veces más (2.630 KB contra 274 a calidad 88, indistinguibles a la vista).

Producción y staging comparten el bucket tcgcards-img, así que hay que garantizar que jamás escriban la misma clave: pisar una ficha sería reemplazarle a un vendedor real su imagen por una de prueba.

La carpeta se deriva del nombre de la base de datos, no de una variable propia. Con una variable aparte bastaría que alguien la olvidara al crear un servicio para que ese entorno heredara la carpeta de producción. Derivándola de la base, escribir en fichas/ exige estar leyendo los usuarios de la base de producción:

BaseCarpeta
api-cards (producción)fichas/
cualquier otrafichas-<nombre de la base>/

Sin MONGODB_DB_NAME se niega a decidir en vez de caer en la de producción. Y hay un segundo cerrojo independiente: el despliegue declara en R2_FICHAS_PREFIJO_ESPERADO qué carpeta espera, y el API aborta si no coincide con la derivada — eso cubre el caso de un servicio de staging apuntado por error a la base de producción.

Límite: 10 regeneraciones al día, con corte a medianoche de America/Santiago. La primera generación no consume cupo. El contador se reclama de forma atómica (findOneAndUpdate con la condición en el filtro, mismo patrón que claimWarning de órdenes): leer-comparar-escribir permitiría saltarse el límite apretando dos veces. Si la generación falla después de tomar el cupo, no se devuelve: deshacer una escritura atómica puede fallar a su vez y dejar el contador peor.

Qué cartas salen. Solo kind: 'card' (los sellados son fotos de producto sobre fondo blanco y rompen la unidad visual). Se representan todos los juegos que el vendedor tiene publicados —uno de cada uno como piso, el resto proporcional al inventario— porque ordenar por fecha daba nueve cartas del mismo juego y eso es publicidad engañosa de su propio inventario. Hasta nueve, sorteadas. Con menos de cuatro cartas propias se genera una pieza genérica con cartas de muestra del sitio, cuyo texto dice «Compra y vende cartas en tcgcards» y nunca menciona una cantidad: esas cartas no son del vendedor. La decisión se toma sobre las que de verdad se pudieron dibujar, no sobre las elegidas.

El selector entrega cinco cartas de más como suplentes: 1 de cada 12 imágenes de TCGplayer devuelve 403 (medido contra inventario real), y sin repuesto esa carta dejaba un agujero negro en el mosaico.

La cifra del pie cuenta todas las publicaciones activas —cartas, sellados y accesorios—, aunque el mosaico dibuje solo cartas: es una decisión de qué se dibuja, no de qué se cuenta.

Este feature no puede dañar al resto del sitio

Sección titulada «Este feature no puede dañar al resto del sitio»

Componer la pieza es lo más caro que hace el API y corre en las mismas instancias que atienden el checkout. Tres límites lo acotan, y ninguno es opcional:

  1. La primera generación se RECLAMA, no se comprueba. Leer «todavía no tiene ficha» y decidir dejaba una ventana del ancho de un render: medido, 25 peticiones simultáneas de una cuenta nueva generaban las 25 sin gastar cupo.
  2. Turno de una a la vez (src/shareCard/services/turno.ts). Una ficha pica en 414 MB y tres a la vez en 537, sobre los 512 del contenedor — y Cloud Run mata la instancia entera, no la petición culpable. Con 80 peticiones admitidas por instancia, sin turno el consumo dependía de cuánta gente apretara el botón. Con turno: 353 MB con diez simultáneas. El turno usa while y no if: con if, una petición fresca se colaba en la misma tanda de microtareas y corrían dos.
  3. Descargas acotadas (src/shareCard/services/descargar.ts): 8 s y 5 MB, leídas por trozos y cortadas al pasarse —medir después de bufferizar no protege la memoria si falta content-length—. La foto de perfil solo se baja de res.cloudinary.com y sin seguir redirecciones: la lista de hosts solo ata el primer salto.

Medido el 2026-08-13, y es la trampa cara de este feature:

  • sharp no sirve para dibujar el texto. Resuelve la fuente contra las del sistema, y node:20-slim no trae ninguna. El fallo es invisible: pedir Inter y pedir una familia inventada producen bytes idénticos.
  • Incrustar la fuente en el SVG tampoco. El motor de SVG de sharp ignora @font-face.
  • resvg sí. Dibuja solo con los archivos que se le pasan, con loadSystemFonts desactivado.

Las fuentes viajan en src/shareCard/assets/ y npm run build las copia a dist/ (scripts/copiar-assets.mjs): tsc solo emite los .ts, así que sin esa copia la imagen sale sin una sola letra y nada lo delata. Por lo mismo el Dockerfile copia scripts/ a la etapa de build.

El QR se incrusta leyendo el viewBox real del SVG generado, nunca un valor fijo: la librería emite 31 o 35 módulos según el largo del usuario, y con una ventana fija el código se recorta y deja de escanear (pasaba desde 22 caracteres) mientras la ficha se ve perfecta.

Las cinco del espejo de imágenes de Mitos —R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, R2_PUBLIC_BASE_URL—, más R2_FICHAS_PREFIJO_ESPERADO y MONGODB_DB_NAME explícita. Si falta alguna, el API arranca igual —tumbar todo el servicio por este feature sería peor— pero deja un aviso en el log nombrando la que falta, y el endpoint responde con ese mismo mensaje en vez del error críptico del SDK.

El servicio necesita 512 MiB. Con 256 el contenedor se cae componiendo, y eso se lleva por delante todo lo que esté atendiendo. En producción está en 512 MiB y 1 CPU: con un núcleo la ficha tarda unos 6-7 s, con dos unos 4 — es velocidad, no seguridad.

El bucket no permite lectura desde otro origen

Sección titulada «El bucket no permite lectura desde otro origen»

img.tcgcards.cl responde sin Access-Control-Allow-Origin (comprobado: 200 sin ese encabezado, y el preflight devuelve 403). Mostrar la imagen no lo necesita —un <img> no pide permiso— pero compartir y descargar sí, porque convierten la ficha en archivo. Por eso el web la pide por GET /api/share-card/imagen, del mismo origen, en vez de directo al bucket. Esa ruta no recibe la dirección por parámetro: la saca del perfil del usuario de la sesión, para no convertirse en un proxy abierto.

Canal separado del transaccional (Resend). Ver Correo masivo.

MétodoPathAuthDescripción
POST/api/v1/marketing/unsubscribeninguna (token firmado)Baja de un clic. Acepta el token por query o por cuerpo. Idempotente
POST/api/v1/marketing/snsninguna (firma SNS + ARN)Webhook de rebotes y quejas de SES
POST/api/v1/admin/campaigns/previewadminDevuelve el HTML tal como llegará
POST/api/v1/admin/campaigns/audience-countadminA cuánta gente llegaría hoy
POST/api/v1/admin/campaigns/testadminEnvía solo al admin autenticado; no registra campaña
POST/api/v1/admin/campaigns/sendadminEnvío real; devuelve { campaignId, recipientCount, sentCount, failedCount }
GET/api/v1/admin/campaignsadminHistorial (últimas 50)

/marketing/sns comprueba dos cosas: la firma de Amazon y que el TopicArn sea el nuestro. La firma sola no basta — cualquier cliente de AWS puede crear un tema y publicar aquí con una firma válida. Además SNS publica con Content-Type: text/plain, así que esa ruta tiene su propio parser de cuerpo; con el express.json() global el cuerpo llegaría vacío y se perderían todos los rebotes en silencio.

El escáner identifica una carta a partir de una foto. El cliente captura el frame de la cámara, lo recorta al guía visual, lo comprime a JPEG (largo máx 1400px, calidad 0.75) y lo manda como data URL base64. El backend lo pasa por OCR.space (Engine 3), extrae el identificador (número de coleccionista) con una regex por TCG y resuelve contra el catálogo (cards). Ver el flujo completo y la migración desde pHash/Tesseract en decisiones técnicas.

MétodoPathAuthDescripción
POST/api/v1/scan/identify-imagenone (rate-limited)Identifica carta desde imagen. Body { tcg, imageBase64 }
GET/api/v1/admin/scanner-usageadminConsumo del cupo de OCR.space (hoy, mes, últimos 30 días, status)
  • Tiene body parser propio de 2 MB (express.json({ limit: '2mb' }) montado antes del global de 100 kb) y un rate limiter propio (scanMatchLimiter).
  • Body (validado con Zod):
    • tcg — slug del TCG (1-64 chars).
    • imageBase64 — data URL data:image/(jpeg|jpg|png|webp);base64,..., máx 1.5 M chars (~1 MB de imagen; tope del free tier de OCR.space). Sobre ese tamaño responde 400 sin gastar cuota.
  • Respuesta 200: { matches: ScanMatchEntry[] } (hasta 5).
    • ScanMatchEntry: { card, distance, confidence } con confidence: 'high' | 'medium'.
    • card: { id, tcg, baseName, setName, setSlug, number, rarity, imageUrl }.
    • matches: [] (vacío) → “no encontrada”.
  • Errores:
    • 400 INVALID_BODYtcg/imageBase64 inválidos.
    • 503 SCANNER_QUOTA_EXCEEDED — se agotó el cupo de OCR.space (free tier). El cliente muestra “no disponible, intenta más tarde”.
    • 502 OCR_UPSTREAM_ERROR — error upstream de OCR.space.
  • Tracking de cuota: cada request incrementa el contador del día en la colección scanner_usage antes de llamar a OCR (refleja “intentos consumidos” aunque el upstream falle).

Respuesta: { data: { today, thisMonth, last30Days: [{ date, count }], dailyLimit: 500, monthlyLimit: 25000, status } }, con status: 'ok' | 'warning' (≥80% diario) | 'critical' (≥100% diario).

MétodoPathAuthDescripción
GET/api/v1/bannersnoneBanners activos
GET/api/v1/admin/bannersadminTodos (admin)
GET/api/v1/admin/banners/:idadminDetalle
POST/api/v1/admin/bannersadminCrea
PATCH/api/v1/admin/banners/:idadminActualiza
POST/api/v1/admin/banners/reorderadminReordena
POST/api/v1/admin/banners/:id/publishadminPublica
POST/api/v1/admin/banners/:id/unpublishadminDespublica
POST/api/v1/admin/banners/:id/archiveadminArchiva
POST/api/v1/admin/banners/:id/restoreadminRestaura
MétodoPathAuthDescripción
GET/api/v1/me/walletuser JWTMi balance
GET/api/v1/me/wallet/entriesuser JWTMi ledger
GET/api/v1/admin/wallet-systemadminBalance + últimas 50 del system wallet
POST/api/v1/admin/wallet-system/owner-drawadminOwner extrae fondos del system wallet
GET/api/v1/admin/users/:userId/walletadminWallet de un user (auditado)
POST/api/v1/admin/users/:userId/wallet/creditadminAcredita
POST/api/v1/admin/users/:userId/wallet/debitadminDebita
POST/api/v1/admin/reconcileadminReconciliación manual
GET/api/v1/admin/finance/overviewadminSobres financieros (ganancia / IVA / usuarios / comisión MP / en tránsito), derivados
GET/api/v1/admin/finance/monthlyadminDesglose por mes (ventas / comisión / IVA / fee MP / ganancia neta)
GET/api/v1/admin/finance/movementsadminMovimientos de las cuentas de sistema (tcgcards + IVA)
POST/api/v1/admin/finance/iva-paymentadminRegistra un pago de IVA al SII (debita la cuenta de sistema de IVA)

Los endpoints de finanzas son read-only/derivados (calculan los sobres leyendo órdenes/payments/ledger; no tocan el flujo de pagos). La única escritura es iva-payment, sobre una cuenta de sistema separada. Ver decisión “Panel de finanzas” en decisiones.

MétodoPathAuthDescripción
GET/api/v1/me/bank-accountuser JWTMi cuenta (encrypted)
PUT/api/v1/me/bank-accountuser JWTCrea/actualiza
DELETE/api/v1/me/bank-accountuser JWTElimina
GET/api/v1/admin/users/:userId/bank-accountadminDecrypted (auditado)
MétodoPathAuthDescripción
POST/api/v1/me/withdrawalsuser JWTSolicita retiro
GET/api/v1/me/withdrawalsuser JWTMis retiros
GET/api/v1/admin/withdrawalsadminLista cola
GET/api/v1/admin/withdrawals/:idadminDetalle
POST/api/v1/admin/withdrawals/:id/completeadminMarca como pagado
POST/api/v1/admin/withdrawals/:id/canceladminCancela
MétodoPathAuthDescripción
POST/api/v1/webhooks/mpMP signatureWebhook de Mercado Pago. Maneja approvals/rejects/late refunds
MétodoPathAuthDescripción
POST/api/v1/admin/moderation/hide-listingadminOculta listing
POST/api/v1/admin/moderation/unhide-listingadminRestaura listing
POST/api/v1/admin/moderation/warn-useradminAdvierte user
POST/api/v1/admin/moderation/suspend-useradminSuspende (7/30/90/null días)
POST/api/v1/admin/moderation/unsuspend-useradminLevanta suspensión
POST/api/v1/admin/moderation/ban-useradminBanea permanente
POST/api/v1/admin/moderation/unban-useradminDesbanea
POST/api/v1/admin/moderation/dismiss-reportadminDesestima 1 report
POST/api/v1/admin/moderation/dismiss-target-reportsadminDesestima todos los reports de un target
MétodoPathAuthDescripción
GET/api/v1/admin/overviewadminDashboard overview
GET/api/v1/admin/ordersadminBúsqueda de órdenes
GET/api/v1/admin/orders/:idadminDetalle
GET/api/v1/admin/usersadminCola moderation
GET/api/v1/admin/users/:username/moderationadminInfo moderation

Estos endpoints son para sincronización del catálogo desde TCGplayer. Usan X-Admin-Token.

MétodoPathAuthDescripción
GET/api/v1/admin/statsadmin tokenStats por TCG
POST/api/v1/admin/sync/:tcgadmin tokenDispara sync (fire-and-forget)
MétodoPathAuthDescripción
POST/api/v1/users/upsertinternal authCrea/actualiza user desde Next.js auth bridge
GET/api/v1/users/meuser JWTMi perfil
PATCH/api/v1/users/meuser JWTEdita perfil
GET/api/v1/users/by-id/:idnonePerfil público por id (incluye delivery, ver Métodos de entrega)
GET/api/v1/users/:usernamenonePerfil público por username (incluye delivery, ver Métodos de entrega)
POST/api/v1/users/me/acknowledge-warninguser JWTReconoce advertencia
GET/api/v1/users/me/can-deleteuser JWTVerifica si puede eliminar cuenta
DELETE/api/v1/users/meuser JWTElimina cuenta (soft delete)
POST/api/v1/me/share-carduser JWTGenera la ficha compartible del perfil (ver abajo)

PATCH /users/me acepta rut (RUT chileno): se valida (módulo 11, acepta K) y se guarda normalizado ("12345678-5"). Es privado — no aparece en el perfil público (/users/:username); solo lo ve el vendedor en la “Información de entrega” de una venta con envío a domicilio (junto a nombre, teléfono y correo del comprador, todos capturados como snapshot en order.buyerAddress al comprar). El checkout de envío a domicilio se bloquea (en el front) si al comprador le faltan RUT/teléfono/dirección.

PATCH /users/me también acepta autoPricing: { enabled, usdRate } — la configuración maestra de Precios dinámicos del vendedor (usdRate = CLP por USD, int 1..100000 o null). Al guardar enabled: false, la API apaga el flag en todas las publicaciones del vendedor en el mismo request. Ver Precios dinámicos en la sección Listings.

Imagen de portada (jul 2026): el usuario tiene coverUrl: string | null (URL de Cloudinary; null = el front dibuja una trama por defecto derivada del nombre de usuario). PATCH /users/me lo acepta y sí aparece en el perfil público: es la misma cabecera en /me y en /u/[username]. Se sube con el kind: 'cover' de /uploads/sign. Aditivo y sin migración: los usuarios sin el campo se comportan como null.

MétodoPathAuthDescripción
POST/api/v1/internal/cron/orders-tickcron secretTick de órdenes (auto-cancel / auto-complete)
POST/api/v1/internal/cron/orders-awaiting-payment-cleanupcron secretCancela órdenes con timeout
POST/api/v1/internal/cron/wallet-reconcilecron secretReconcilia wallet
POST/api/v1/internal/cron/refunds-reconcilecron secretReconcilia refunds

Devuelve { data: { released, upcoming } }: los sets comprables de más nuevo a más viejo —el primero con arte es el destacado— y los que salen este mes pero todavía no, de más cercano a más lejano. Cada set trae sus campos showcase*.

Existe aparte de /sets porque la portada pedía los sets de cada juego por separado —nueve peticiones para doce tarjetas— y aplicaba en el cliente la regla de qué entra. Ahora es una consulta y una sola regla, compartida con el job que espeja el arte: si eligieran distinto, la portada mostraría sets que el job nunca miró, y esos quedarían con el logo para siempre.

Qué entra, en este orden:

  1. La ventana del mes. Solo sets con fecha en el mes en curso o anteriores. Esta regla también vive en el web (src/lib/format.ts, isSetCurrentlyReleased) porque /search la usa; ambas copias se prueban con los mismos casos.
  2. Mínimo 10 cartas cargadas. No es estético: la cantidad miente los primeros días. «Attack of the Vine!» figuraba con 3 cartas cuando tiene 261, y cuatro de los seis mazos de One Piece con 9 de las ~50 que tendrán.
  3. Partición por día dentro de la ventana. La regla del mes es gruesa: un set del 31 visto el 26 ya entra pero no se puede comprar, y al ordenar por fecha se iba al primer lugar. Medido en producción, los siete primeros eran sets no comprables.
  4. Cupos: 13 sets, con al menos 6 lugares reservados a lo comprable — no se recorta para hacerle sitio a lo que aún no se vende.

La fecha de lanzamiento se trata como día de calendario, no como instante: se comparan componentes en UTC y nunca se convierte a hora local. «Vendetta» está guardada como 2026-07-31T00:00:00Z, que en Chile es el 30 a las 20:00; convertir correría todos los lanzamientos un día hacia atrás.

Un set sin cartas no existe para el usuario (ago 2026)

Sección titulada «Un set sin cartas no existe para el usuario (ago 2026)»

GET /sets filtra cardsCount > 0 —también en el total de la paginación, o la última página saldría vacía— y GET /sets/:id responde 404 en vez de servir una página en blanco.

Por qué: el slug de un set de TCGplayer es su urlValue y el _id se arma con él. Cuando TCGplayer renombra un set nace un documento NUEVO, las cartas se mudan al nuevo y el viejo queda huérfano. El buscador mostraba DOS «Starter Deck 36: YELLOW Eustass”Captain”Kid» idénticos, uno vacío, y el vacío llegó a ser el destacado de la portada.

Por qué el filtro va sobre el conteo y no sobre el nombre: adivinar “este set es el mismo renombrado” comparando nombres fusionaría sets realmente distintos — en Mitos hay dos «Raciales» legítimos, con 144 y 72 cartas. Quedarse sin cartas es la consecuencia observable y sin ambigüedad de un set muerto.

Se apoya en que cardsCount diga la verdad, que garantizan el sync y la familia (d) de reconcile-denorm. Medido al desplegarlo: de 1.642 sets dejaron de verse 3, los tres realmente vacíos.

El limitador global (600 req/min, ver Notas técnicas) contaba por defecto por req.ip — la IP de origen de la petición. Pero el 99% del tráfico de la API llega desde el renderizado del sitio en Vercel, por solo 3 IP (3.238.104.135, 44.222.179.158, 18.213.247.219): todos los visitantes reales compartían esas 3 cubetas, así que un solo rastreador agresivo podía agotarlas y dejar el sitio caído para el resto.

Pasó de verdad el 2026-08-07, entre las 05:49 y las 05:50 UTC: un rastreo pidió ~570 fichas de carta en dos minutos, el tráfico saltó de ~80 a 1.504 peticiones por minuto, el limitador devolvió 180 respuestas 429, y el web las convirtió en ~75 páginas con error 500 para terceros que no tenían nada que ver con el rastreo.

La llave ahora es el visitante, no el origen de la petición. El web adjunta la cabecera X-Client-IP con la IP de quien navega, junto con X-Internal-Auth (el mismo secreto interno server-to-server, comparado en tiempo constante). Sin esa firma el limitador ignora X-Client-IP y cae al comportamiento de siempre —cobrarle a la IP de origen—: confiar en la cabecera sin autenticarla sería peor que el problema original, porque cualquiera mandaría una IP distinta en cada petición y quedaría sin límite. Las IPv6 se agrupan por /56 (ipv6Subnet por defecto de ipKeyGenerator en express-rate-limit 8.4.1, verificado en el fuente), para que rotar de dirección dentro del mismo bloque no sirva para evadir el límite.

Cómo el web obtiene la IP real, considerando a Cloudflare. Producción va detrás de Cloudflare (verificado: responde server: cloudflare con cf-ray), y ahí Vercel sobrescribe X-Forwarded-For con la IP de Cloudflare antes de que llegue a Next.js — usarla como primera fuente volvería a juntar a todos los visitantes bajo una sola IP, la cubeta compartida que este cambio vino a evitar. Por eso el web prefiere cf-connecting-ip (o true-client-ip) y deja x-forwarded-for como respaldo, que es lo único que llega en staging, que NO está detrás de Cloudflare (responde server: Vercel).

Pero esas dos cabeceras solo valen con el sello. Ver El sello de origen, más abajo: sin él se podía suplantar a un visitante concreto y dejarlo sin servicio.

El sitio se sirve solo bajo su dominio. Un middleware del web redirige con 308 cualquier host que no sea tcgcards.cl, www.tcgcards.cl, staging.tcgcards.cl, localhost o una vista previa tcgcards-web-git-*.vercel.app (ver Middleware de dominio). Existe porque se verificó que https://tcgcards-web.vercel.app/ —el dominio que Vercel publica por defecto para todo despliegue— servía producción completa, con 200 y sin login, saltándose Cloudflare por completo: desde ahí se podía inventar una CF-Connecting-IP y evadir el límite de arriba. Además esa copia le ofrecía a Google un sitio duplicado, con Allow: / en su robots.txt.

Un 429 ya no rompe la página. Antes se relanzaba y Next.js renderizaba la pantalla de error genérica (500 para el visitante y para Vercel). Ahora la ficha de carta (/cards/[id]) muestra un aviso de tráfico con botón de reintentar.

Caché de la ficha de carta. GET /cards/:id pasó de no-store a 60 s de caché en el web, y las ventas recientes a 300 s (antes 60 s): casi todo lo que devuelve una carta es inmutable, y el resumen de stock ya es un denormalizado, no un dato en vivo. Las publicaciones de una carta (GET /cards/:id/listings) siguen sin cachear, a propósito: son el precio y el stock con los que alguien decide comprar.

La ventana es de 60 s y arranca en la primera petición de ese visitante, no en un reloj global compartido: MemoryStore.increment() sólo llama a resetClient() cuando resetTime <= now, y esa hora se fija al abrir la ventana.

De ahí se sigue algo que conviene saber al responder reclamos: insistir no alarga el bloqueo. No hay castigo escalonado ni lista negra. Cumplida la ventana, el contador vuelve a cero con la cuota entera. La espera máxima es de 60 s; si alguien se satura al segundo 50 de su ventana, espera 10.

Cada respuesta trae ratelimit: limit=600, remaining=N, reset=S (standardHeaders: 'draft-7'), con los segundos que faltan. El web no muestra ese número: se decidió un mensaje genérico de sobrecarga, sin cuenta regresiva.

El limitador por visitante de arriba tenía un agujero que lo dejaba peor que inútil contra un atacante decidido: no solo se podía evadir, se podía usar para elegir a una persona y dejarla sin servicio.

El origen de Vercel es alcanzable directamente con el Host legítimo:

curl --resolve tcgcards.cl:443:64.29.17.3 https://tcgcards.cl/ → 200, server: Vercel, sin cf-ray

Cloudflare sobrescribe CF-Connecting-IP solo cuando la petición pasa por Cloudflare. Llegando directo al origen, nadie la sobrescribe: se escribe a mano. Medido en producción el 2026-08-07: 10 cargas de página declarando ser 203.0.113.144 le bajaron la cuota a esa IP de 599 a 578. Con ~300 cargas se agota la de cualquiera, y repitiéndolo cada minuto se la deja fuera del sitio indefinidamente.

La señal correcta es algo que solo nuestra zona pueda producir. Una regla Transform de Cloudflare («Sello origen vercel», All incoming requestsSet static) añade x-tcgcards-edge con un secreto a todo lo que manda al origen. El web solo cree a cf-connecting-ip / true-client-ip cuando esa cabecera coincide con CLOUDFLARE_EDGE_SECRET (comparación en tiempo constante, con chequeo de largo primero). La decisión vive en src/lib/api/ipVisitante.ts, función pura con 10 pruebas.

Quién llega¿Sello?Qué se le cobra
Visitante normalSu IP real
Atacante directo al origen de VercelNoSu propia IP
Atacante vía su propio CloudflareNoLa IP por la que entró
Staging (sin Cloudflare)NoLa IP real del visitante — correcto ahí

El respaldo es seguro. Vercel sobrescribe x-vercel-forwarded-for y x-forwarded-for con la IP de quien realmente abrió la conexión. Verificado midiendo: falsificar cualquiera de las dos no movió la cuota de la IP declarada (599 → 598, solo la sonda).

Notas de operación. El secreto es distinto del interno del API: si se reutilizara, quedaría copiado también en la configuración de Cloudflare y quien tuviera acceso ahí podría hablarle al API haciéndose pasar por el web. CLOUDFLARE_EDGE_SECRET va solo en Production y sin prefijo NEXT_PUBLIC_ (verificado que no aparece en .next/static); staging y local no lo llevan a propósito, y sin él el respaldo ES la IP real del visitante — no hace falta ninguna excepción por entorno. Rotarlo es cambiar el valor en los dos lados, sin desplegar. Este cambio no rechaza ninguna petición: solo decide a qué cubeta se cobra, así que su peor falla es volver a cubetas compartidas, nunca una caída.

Lo que sigue abierto. La puerta al origen de Vercel no se cerró: un atacante puede llegar ahí y consumir su propia cuota, y sigue esquivando cualquier protección futura de Cloudflare (incluidas sus reglas de rate limiting, la única capa que frena a quien rota IP). Cerrarla requiere una regla de firewall, y con el comportamiento errático de arriba se decidió no tocarla a ciegas.

Verificación del sello en producción (2026-08-07)

Sección titulada «Verificación del sello en producción (2026-08-07)»

Hacen falta dos pruebas, no una: si el sello no llegara, la del ataque igual saldría bien —el ataque se bloquea de todos modos— pero estaríamos sin identificar a nadie y todos los visitantes de vuelta en cubetas compartidas.

PruebaAntesDespués
A — ataque: 10 cargas suplantando a un tercero599 → 578599 → 598 (solo la sonda)
B — visitante normal: 3 cargas por Cloudflare599 → 592575 → 568 (baja 7, idéntico)

A dice que el ataque murió; B dice que no se mató rompiendo la identificación.

Se probó contra prod usando una ruta inexistente (/api/v1/__sonda-limitador): igual pasa por el limitador y devuelve las cabeceras ratelimit, sin tocar la base de datos ni consumir la cuota de nadie real. Con IP de documentación (198.51.100.x, 203.0.113.x, 192.0.2.x) y el secreto de gcloud secrets versions access latest --secret=internal-auth-secret:

PruebaResultado
Dos visitantes distintos599 y 599 — cubetas separadas
El mismo, tres veces598, 597, 596
Falsificar X-Client-IP sin firmaIgnorada, cae a la cubeta de la IP de origen
Cubeta amplia de relleno sin el secretoInalcanzable
Saturación: 620 peticiones de una IPPasan exactamente 600, se rechazan 20
Con ese abusador en 429Otros dos visitantes pasan; tcgcards.cl responde 200 en portada, feed y ficha
Insistir durante el bloqueo (150 peticiones extra)reset sigue bajando parejo; vuelve a pasar a los 60 s exactos

Que el web reenvía la IP real es lo único que no se puede verificar en staging (staging no está detrás de Cloudflare). Se comprueba así: sondear con la propia IP pública, navegar N veces por tcgcards.cl y sondear otra vez. Medido: 598 → 591 con 6 visitas — las visitas se cobraron en la cubeta del visitante, no en la de Vercel.

  • Rate limits globales: 600 req/min, contados por visitante real desde ago 2026 (antes por IP de origen — ver Rate limiting por visitante). Endpoints con limiter propio: uploads, listings POST, chat, reviews, scan (scanMatchLimiter), Precios dinámicos (toggle por carta 60/min; apply-all y recalc 10/min).
  • Amounts: siempre CLP centavos (integer). Nunca pesos. Mostrarse con formatPriceCents().
  • Paginación:
    • ?page=N (offset-based) para queries simples.
    • ?cursor=<iso> o ?offset=<iso> (cursor-based) para feeds grandes.
  • Timestamps: ISO 8601 UTC.
  • Phone format: 569XXXXXXXX (E.164 Chile, privado).