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
Autenticación
Sección titulada «Autenticación»Hay 4 tipos de auth distintos según el endpoint:
| Tipo | Cómo se envía | Quién la usa |
|---|---|---|
none | — | Endpoints públicos (catálogo, feeds) |
user JWT | Authorization: Bearer <jwt> | Login con Google → JWT propio firmado HS256 |
admin | user JWT + user.isAdmin === true | Solo admins del marketplace |
admin token | X-Admin-Token: <token> | Endpoints de catálogo sync — usa ADMIN_TOKEN env var |
internal auth | X-Internal-Auth: <secret> | Server-to-server (Next.js → API). Usa INTERNAL_AUTH_SECRET |
internal cron | X-Internal-Cron-Secret: <secret> | Solo Cloud Scheduler. Usa INTERNAL_CRON_SECRET |
Catálogo (público)
Sección titulada «Catálogo (público)»| Método | Path | Auth | Descripción |
|---|---|---|---|
| GET | /api/v1/health | none | Healthcheck (devuelve status de conexión Mongo) |
| GET | /api/v1/tcgs | none | Lista TCGs habilitados |
| GET | /api/v1/tcgs/:slug | none | TCG específico |
| GET | /api/v1/tcgs/:slug/rarities | none | Rarezas disponibles |
| GET | /api/v1/sets | none | Lista paginada ?tcg=. No devuelve sets sin cartas (ver abajo) |
| GET | /api/v1/sets/recent | none | Sección «Nuevos lanzamientos» de la portada, resuelta: { released, upcoming } (ver abajo) |
| GET | /api/v1/sets/:id | none | Set específico. 404 si el set no tiene cartas (ver abajo) |
| GET | /api/v1/cards | none | Busca/filtra cards con facetas |
| GET | /api/v1/cards/suggest | none | Autocomplete |
| GET | /api/v1/cards/counts | none | Conteo de matches por TCG para ?q= (1 consulta) |
| GET | /api/v1/cards/:id | none | Card específica |
| GET | /api/v1/cards/:id/listings | none | Listings activos de esa card |
| GET | /api/v1/cards/:id/recent-sales | none | Últimas 5 ventas |
| GET | /api/v1/sitemap/cards | none | Cards para sitemap XML |
| GET | /api/v1/sealed-products/sets | none | Sets que tienen sellados de ese ?tcg=. Primer paso del selector al publicar (ver abajo) |
| GET | /api/v1/sealed-products | none | Sellados del catálogo: ?tcg=&set=&q=&page=&limit= (máx 50) |
| GET | /api/v1/sealed-products/:id | none | Un sellado. 404 con el id de una carta |
| GET | /api/v1/sealed-products/:id/listings | none | Vendedores que lo tienen |
| GET | /api/v1/sitemap/users | none | Usernames públicos para sitemap |
Filtros por TCG (atributos)
Sección titulada «Filtros por TCG (atributos)»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($inpara arrays/escalares; los multi-valor consplitOnusan regex). - Respuesta
facets.attributes: junto asetsyrarities,/cardsdevuelvefacets.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 ($searchcomo stage líder, mismo índicecard-searchque 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. qmatchea nombre O número (jun 2026): además del nombre,qbusca por número de carta (ej.97,97/101,OP09-095) vía el camponumberSearch(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 asearchSellerActiveListings(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.inStockes dinámico: coninStock=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).
Conteo por TCG (búsqueda global)
Sección titulada «Conteo por TCG (búsqueda global)»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 (
$searchsobre el índicecard-search, mismas cláusulas de autocomplete que/cards) +$groupportcg, con fallback a$regexsobrebaseNamesi Atlas no está disponible. qvací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.
Catálogo de productos sellados (ago 2026)
Sección titulada «Catálogo de productos sellados (ago 2026)»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.
Viven en la MISMA colección que las cartas
Sección titulada «Viven en la MISMA colección que las cartas»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.
Un sellado NO lleva precio de referencia
Sección titulada «Un sellado NO lleva precio de referencia»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.
El idioma vive en la publicación, no en el catálogo
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.
Publicar: cardId o sealedTitle, nunca ambos
Sección titulada «Publicar: cardId o sealedTitle, nunca ambos»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'.
Listings (catálogo de mercado)
Sección titulada «Listings (catálogo de mercado)»| Método | Path | Auth | Descripción |
|---|---|---|---|
| GET | /api/v1/listings | none | Búsqueda general (admin-grade) |
| GET | /api/v1/listings/cards | none | Feed 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/facets | none | Facetas 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/bulk | none | Feed público de challa |
| GET | /api/v1/listings/bulk/facets | none | Facetas challa |
| GET | /api/v1/listings/sealed | none | Feed público de sellado |
| GET | /api/v1/listings/sealed/facets | none | Facetas sellado |
| GET | /api/v1/accessories | none | Feed de accesorios |
| GET | /api/v1/accessories/facets | none | Facetas accesorios |
| GET | /api/v1/listings/decks | none | Feed público de mazos |
| GET | /api/v1/listings/decks/facets | none | Facetas decks (counts por TCG) |
| GET | /api/v1/listings/custom | none | Custom listings (legacy) |
| GET | /api/v1/listings/:id/public | none | Listing público |
| GET | /api/v1/listings/:id | none | Listing detallado |
| POST | /api/v1/listings | user JWT + active | Crea listing |
| POST | /api/v1/listings/batch | user JWT + active | Publicación masiva (Publicador Pro): crea N publicaciones card en un request, resultado por carta (éxito parcial), idempotente por clientRef |
| PATCH | /api/v1/listings/:id | user JWT + active | Actualiza listing propio |
| DELETE | /api/v1/listings/:id | user JWT | Elimina listing propio |
| GET | /api/v1/users/me/listings | user JWT | Mis listings con filtros |
| GET | /api/v1/users/me/listings/facets | user JWT | Facetas de mis listings (incluye autoPriceEligibleCount = nº de cartas activas de TCGplayer, lo que actualiza “activar en todas”) |
| POST | /api/v1/users/me/listings/bulk | user JWT | Acción bulk: pause/activate/mark_sold/delete |
| POST | /api/v1/listings/:id/auto-price | user JWT + active | Activa/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-all | user JWT + active | Activa/desactiva Precios dinámicos en todas las cartas elegibles del vendedor (body { enabled }). Rate-limited 10/min |
| POST | /api/v1/me/listings/auto-price/recalc | user JWT + active | Recalcula 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-refresh | none | Timestamp del último refresco de precios de TCGplayer |
| GET | /api/v1/users/:username/listings | none | Listings públicos de un seller |
| GET | /api/v1/users/:username/listings/facets | none | Facetas 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.
Mazos (decks)
Sección titulada «Mazos (decks)»-
GET /api/v1/listings/decks— feed público de mazos. Query params:q(string, opc): búsqueda case-insensitive endeckTitle.tcg(string slug, opc): filtra por TCG.sort(recent|price-asc|price-desc, defaultrecent).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/listingsconkind: '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 camposdeckTitleydeckCardCountademás de los comunes. -
POST /api/v1/listingsconkind: 'card'— aceptaphotos(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. ElPATCHtambién las admite (cap 2).
Precios dinámicos
Sección titulada «Precios dinámicos»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)
usdRatees 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”.)
Carrito
Sección titulada «Carrito»| Método | Path | Auth | Descripción |
|---|---|---|---|
| GET | /api/v1/me/cart | user JWT | Mi carrito enriquecido |
| POST | /api/v1/me/cart/items | user JWT + active | Agrega item |
| PATCH | /api/v1/me/cart/items/:listingId | user JWT + active | Actualiza cantidad (0 = elimina) |
| DELETE | /api/v1/me/cart/items/:listingId | user JWT + active | Elimina item |
| DELETE | /api/v1/me/cart | user JWT + active | Vacía carrito |
| POST | /api/v1/me/cart/checkout | user JWT + active | Checkout del carrito (multi-vendedor) |
| POST | /api/v1/me/checkout/confirm-redirect | user JWT | Confirma 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 vendedor →
OrderService.createOrderWithPayment(flujo individual de siempre, reembolso a tarjeta vía MP) → responde{ kind: 'order', orderId, mpInitPoint }. - 2+ vendedores →
CheckoutService.createCartCheckout: crea una orden por vendedor + un agregadocheckout, 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; method ∈ shipping/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 (getPayment — no 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étodos de entrega
Sección titulada «Métodos de entrega»| Método | Path | Auth | Descripción |
|---|---|---|---|
| GET | /api/v1/me/delivery | user JWT | Mi configuración de entrega (config efectiva) |
| PUT | /api/v1/me/delivery | user JWT + active | Actualiza 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) aceptadeliveries[]con la elección de método por vendedor, incluyendopickup+pickupPointId.- El perfil público (
GET /users/:username,GET /users/by-id/:id) y cada grupo del carrito enriquecido exponendelivery: {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/instructionsnunca se exponen ahí — son privados hasta la compra. PATCH /orders/:id/ship(ver Órdenes) aceptadeliveryMethod: '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.
Órdenes
Sección titulada «Órdenes»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/orders/checkout | user JWT + active | Crea orden + MP preference |
| GET | /api/v1/orders/:id | user JWT | Detalle (buyer o seller) |
| POST | /api/v1/orders/:id/resume-payment | user JWT + active | Reanuda pago si quedó en awaiting_payment |
| PATCH | /api/v1/orders/:id/accept | user JWT (seller) | Acepta |
| PATCH | /api/v1/orders/:id/ship | user JWT (seller) | Marca como enviado/entregado. deliveryMethod ∈ shipping/in_person/pickup |
| PATCH | /api/v1/orders/:id/complete | user JWT (buyer) | Confirma recepción |
| PATCH | /api/v1/orders/:id/cancel | user JWT | Cancela. 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/whatsapp | user JWT | Devuelve WhatsApp de la contraparte si activó esa preferencia |
| GET | /api/v1/me/orders/buyer | user JWT | Mis compras (cada orden incluye counterpart = vendedor: {id, username, name, avatarUrl}) |
| GET | /api/v1/me/orders/seller | user JWT | Mis ventas (cada orden incluye counterpart = comprador: {id, username, name, avatarUrl}) |
| GET | /api/v1/me/orders/counts | user JWT | Conteos 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étodo | Path | Auth | Descripción |
|---|---|---|---|
| GET | /api/v1/me/unread-count | user JWT | # de chats sin leer |
| GET | /api/v1/me/conversations | user JWT | Mis conversaciones recientes |
| GET | /api/v1/conversations/:orderId/messages | user JWT | Mensajes de la orden |
| POST | /api/v1/conversations/:orderId/messages | user JWT | Envía mensaje (rate-limited) |
Reviews
Sección titulada «Reviews»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/orders/:orderId/review | user JWT | Deja review (después de completar) |
| GET | /api/v1/orders/:orderId/review | user JWT | Mi review en esa orden |
| GET | /api/v1/users/:username/reviews | none | Reviews recibidas por un seller |
Disputes
Sección titulada «Disputes»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/orders/:id/dispute | user JWT | Abre dispute (buyer o seller) |
| GET | /api/v1/orders/:id/dispute | user JWT | Detalle dispute |
| POST | /api/v1/orders/:id/dispute/response | user JWT | Responde dispute (la otra parte) |
| GET | /api/v1/admin/disputes | admin | Cola de disputes |
| GET | /api/v1/admin/disputes/:id | admin | Detalle admin |
| POST | /api/v1/admin/disputes/:id/resolve | admin | Resuelve (outcome + adminNote) |
Reports (moderación)
Sección titulada «Reports (moderación)»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/reports | user JWT | Reporta listing o user |
| GET | /api/v1/admin/reports | admin | Lista agrupada |
| GET | /api/v1/admin/reports/:id | admin | Detalle |
Uploads
Sección titulada «Uploads»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/uploads/sign | user JWT | Firma 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 unpublicIdúnico, a diferencia deavatarocover, 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.
Ficha compartible del perfil (ago 2026)
Sección titulada «Ficha compartible del perfil (ago 2026)»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.
| Respuesta | Cuerpo |
|---|---|
200 | { url, restantes, conCartasPropias } |
401 | sin 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).
Cada entorno escribe en su propia carpeta
Sección titulada «Cada entorno escribe en su propia carpeta»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:
| Base | Carpeta |
|---|---|
api-cards (producción) | fichas/ |
| cualquier otra | fichas-<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:
- 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.
- 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 usawhiley noif: conif, una petición fresca se colaba en la misma tanda de microtareas y corrían dos. - 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 faltacontent-length—. La foto de perfil solo se baja deres.cloudinary.comy sin seguir redirecciones: la lista de hosts solo ata el primer salto.
La tipografía no se resuelve con sharp
Sección titulada «La tipografía no se resuelve con sharp»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-slimno trae ninguna. El fallo es invisible: pedirIntery 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
loadSystemFontsdesactivado.
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.
Variables de entorno y recursos
Sección titulada «Variables de entorno y recursos»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.
Correo masivo
Sección titulada «Correo masivo»Canal separado del transaccional (Resend). Ver Correo masivo.
| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/marketing/unsubscribe | ninguna (token firmado) | Baja de un clic. Acepta el token por query o por cuerpo. Idempotente |
| POST | /api/v1/marketing/sns | ninguna (firma SNS + ARN) | Webhook de rebotes y quejas de SES |
| POST | /api/v1/admin/campaigns/preview | admin | Devuelve el HTML tal como llegará |
| POST | /api/v1/admin/campaigns/audience-count | admin | A cuánta gente llegaría hoy |
| POST | /api/v1/admin/campaigns/test | admin | Envía solo al admin autenticado; no registra campaña |
| POST | /api/v1/admin/campaigns/send | admin | Envío real; devuelve { campaignId, recipientCount, sentCount, failedCount } |
| GET | /api/v1/admin/campaigns | admin | Historial (ú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.
Scanner (identificación por OCR)
Sección titulada «Scanner (identificación por OCR)»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étodo | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/scan/identify-image | none (rate-limited) | Identifica carta desde imagen. Body { tcg, imageBase64 } |
| GET | /api/v1/admin/scanner-usage | admin | Consumo del cupo de OCR.space (hoy, mes, últimos 30 días, status) |
POST /api/v1/scan/identify-image
Sección titulada «POST /api/v1/scan/identify-image»- 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 URLdata: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 responde400sin gastar cuota.
- Respuesta
200:{ matches: ScanMatchEntry[] }(hasta 5).ScanMatchEntry:{ card, distance, confidence }conconfidence: 'high' | 'medium'.card:{ id, tcg, baseName, setName, setSlug, number, rarity, imageUrl }.matches: [](vacío) → “no encontrada”.
- Errores:
400 INVALID_BODY—tcg/imageBase64invá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_usageantes de llamar a OCR (refleja “intentos consumidos” aunque el upstream falle).
GET /api/v1/admin/scanner-usage
Sección titulada «GET /api/v1/admin/scanner-usage»Respuesta: { data: { today, thisMonth, last30Days: [{ date, count }], dailyLimit: 500, monthlyLimit: 25000, status } }, con status: 'ok' | 'warning' (≥80% diario) | 'critical' (≥100% diario).
Banners (homepage)
Sección titulada «Banners (homepage)»| Método | Path | Auth | Descripción |
|---|---|---|---|
| GET | /api/v1/banners | none | Banners activos |
| GET | /api/v1/admin/banners | admin | Todos (admin) |
| GET | /api/v1/admin/banners/:id | admin | Detalle |
| POST | /api/v1/admin/banners | admin | Crea |
| PATCH | /api/v1/admin/banners/:id | admin | Actualiza |
| POST | /api/v1/admin/banners/reorder | admin | Reordena |
| POST | /api/v1/admin/banners/:id/publish | admin | Publica |
| POST | /api/v1/admin/banners/:id/unpublish | admin | Despublica |
| POST | /api/v1/admin/banners/:id/archive | admin | Archiva |
| POST | /api/v1/admin/banners/:id/restore | admin | Restaura |
| Método | Path | Auth | Descripción |
|---|---|---|---|
| GET | /api/v1/me/wallet | user JWT | Mi balance |
| GET | /api/v1/me/wallet/entries | user JWT | Mi ledger |
| GET | /api/v1/admin/wallet-system | admin | Balance + últimas 50 del system wallet |
| POST | /api/v1/admin/wallet-system/owner-draw | admin | Owner extrae fondos del system wallet |
| GET | /api/v1/admin/users/:userId/wallet | admin | Wallet de un user (auditado) |
| POST | /api/v1/admin/users/:userId/wallet/credit | admin | Acredita |
| POST | /api/v1/admin/users/:userId/wallet/debit | admin | Debita |
| POST | /api/v1/admin/reconcile | admin | Reconciliación manual |
| GET | /api/v1/admin/finance/overview | admin | Sobres financieros (ganancia / IVA / usuarios / comisión MP / en tránsito), derivados |
| GET | /api/v1/admin/finance/monthly | admin | Desglose por mes (ventas / comisión / IVA / fee MP / ganancia neta) |
| GET | /api/v1/admin/finance/movements | admin | Movimientos de las cuentas de sistema (tcgcards + IVA) |
| POST | /api/v1/admin/finance/iva-payment | admin | Registra 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.
Cuentas bancarias
Sección titulada «Cuentas bancarias»| Método | Path | Auth | Descripción |
|---|---|---|---|
| GET | /api/v1/me/bank-account | user JWT | Mi cuenta (encrypted) |
| PUT | /api/v1/me/bank-account | user JWT | Crea/actualiza |
| DELETE | /api/v1/me/bank-account | user JWT | Elimina |
| GET | /api/v1/admin/users/:userId/bank-account | admin | Decrypted (auditado) |
Withdrawals
Sección titulada «Withdrawals»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/me/withdrawals | user JWT | Solicita retiro |
| GET | /api/v1/me/withdrawals | user JWT | Mis retiros |
| GET | /api/v1/admin/withdrawals | admin | Lista cola |
| GET | /api/v1/admin/withdrawals/:id | admin | Detalle |
| POST | /api/v1/admin/withdrawals/:id/complete | admin | Marca como pagado |
| POST | /api/v1/admin/withdrawals/:id/cancel | admin | Cancela |
Webhook MP
Sección titulada «Webhook MP»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/webhooks/mp | MP signature | Webhook de Mercado Pago. Maneja approvals/rejects/late refunds |
Admin — moderation
Sección titulada «Admin — moderation»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/admin/moderation/hide-listing | admin | Oculta listing |
| POST | /api/v1/admin/moderation/unhide-listing | admin | Restaura listing |
| POST | /api/v1/admin/moderation/warn-user | admin | Advierte user |
| POST | /api/v1/admin/moderation/suspend-user | admin | Suspende (7/30/90/null días) |
| POST | /api/v1/admin/moderation/unsuspend-user | admin | Levanta suspensión |
| POST | /api/v1/admin/moderation/ban-user | admin | Banea permanente |
| POST | /api/v1/admin/moderation/unban-user | admin | Desbanea |
| POST | /api/v1/admin/moderation/dismiss-report | admin | Desestima 1 report |
| POST | /api/v1/admin/moderation/dismiss-target-reports | admin | Desestima todos los reports de un target |
Admin — overview + orders + users
Sección titulada «Admin — overview + orders + users»| Método | Path | Auth | Descripción |
|---|---|---|---|
| GET | /api/v1/admin/overview | admin | Dashboard overview |
| GET | /api/v1/admin/orders | admin | Búsqueda de órdenes |
| GET | /api/v1/admin/orders/:id | admin | Detalle |
| GET | /api/v1/admin/users | admin | Cola moderation |
| GET | /api/v1/admin/users/:username/moderation | admin | Info moderation |
Admin — catálogo (sync)
Sección titulada «Admin — catálogo (sync)»Estos endpoints son para sincronización del catálogo desde TCGplayer. Usan X-Admin-Token.
| Método | Path | Auth | Descripción |
|---|---|---|---|
| GET | /api/v1/admin/stats | admin token | Stats por TCG |
| POST | /api/v1/admin/sync/:tcg | admin token | Dispara sync (fire-and-forget) |
Usuarios
Sección titulada «Usuarios»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/users/upsert | internal auth | Crea/actualiza user desde Next.js auth bridge |
| GET | /api/v1/users/me | user JWT | Mi perfil |
| PATCH | /api/v1/users/me | user JWT | Edita perfil |
| GET | /api/v1/users/by-id/:id | none | Perfil público por id (incluye delivery, ver Métodos de entrega) |
| GET | /api/v1/users/:username | none | Perfil público por username (incluye delivery, ver Métodos de entrega) |
| POST | /api/v1/users/me/acknowledge-warning | user JWT | Reconoce advertencia |
| GET | /api/v1/users/me/can-delete | user JWT | Verifica si puede eliminar cuenta |
| DELETE | /api/v1/users/me | user JWT | Elimina cuenta (soft delete) |
| POST | /api/v1/me/share-card | user JWT | Genera 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.
Internal cron
Sección titulada «Internal cron»| Método | Path | Auth | Descripción |
|---|---|---|---|
| POST | /api/v1/internal/cron/orders-tick | cron secret | Tick de órdenes (auto-cancel / auto-complete) |
| POST | /api/v1/internal/cron/orders-awaiting-payment-cleanup | cron secret | Cancela órdenes con timeout |
| POST | /api/v1/internal/cron/wallet-reconcile | cron secret | Reconcilia wallet |
| POST | /api/v1/internal/cron/refunds-reconcile | cron secret | Reconcilia refunds |
GET /sets/recent — la portada, resuelta
Sección titulada «GET /sets/recent — la portada, resuelta»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:
- 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/searchla usa; ambas copias se prueban con los mismos casos. - 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.
- 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.
- 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.
Rate limiting por visitante (ago 2026)
Sección titulada «Rate limiting por visitante (ago 2026)»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.
Cómo se comporta el castigo
Sección titulada «Cómo se comporta el castigo»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 sello de origen (ago 2026)
Sección titulada «El sello de origen (ago 2026)»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-rayCloudflare 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 requests → Set 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 normal | Sí | Su IP real |
| Atacante directo al origen de Vercel | No | Su propia IP |
| Atacante vía su propio Cloudflare | No | La IP por la que entró |
| Staging (sin Cloudflare) | No | La 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.
| Prueba | Antes | Después |
|---|---|---|
| A — ataque: 10 cargas suplantando a un tercero | 599 → 578 | 599 → 598 (solo la sonda) |
| B — visitante normal: 3 cargas por Cloudflare | 599 → 592 | 575 → 568 (baja 7, idéntico) |
A dice que el ataque murió; B dice que no se mató rompiendo la identificación.
Verificación en producción (2026-08-07)
Sección titulada «Verificación en producción (2026-08-07)»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:
| Prueba | Resultado |
|---|---|
| Dos visitantes distintos | 599 y 599 — cubetas separadas |
| El mismo, tres veces | 598, 597, 596 |
Falsificar X-Client-IP sin firma | Ignorada, cae a la cubeta de la IP de origen |
| Cubeta amplia de relleno sin el secreto | Inalcanzable |
| Saturación: 620 peticiones de una IP | Pasan exactamente 600, se rechazan 20 |
Con ese abusador en 429 | Otros 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.
Notas técnicas
Sección titulada «Notas técnicas»- 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).