From ff4237eced4d57757dabd4a47071f8b5cf06fae2 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:02:59 -0500 Subject: [PATCH] docs: contrato real de la API v1 para el equipo integrador MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responde las 10 preguntas que mandaron sobre admin.u-site.app leyendo el código del servidor en vez de inferirlas desde el cliente, con cita de archivo y línea en cada una. Lo que sale de ahí: - expires_in sí es timestamp absoluto (su lectura era correcta), pero el token se emite con 90 años de vigencia porque Login no pasa vencimiento. - La API v1 NO acepta Bearer: autentica solo por la cookie Verify-Rest-Token, así que el placeholder "..." sin completar deja los 12 endpoints en 401. Es la causa más probable de que la integración nunca haya autenticado por sí misma. - Hay DOS textos de error 401 ("Token not found" y "Invalid Attempt"), y su reintento solo cubre el segundo. - El envoltorio doble-codificado de dLocal es real, nadie lo adivinó mal. - generate-qr devuelve imagen binaria, no JSON. Incluye los pendientes de nuestro lado (vigencia del token, Secure=false en la cookie, auth por cookie en una API máquina-a-máquina) y la recomendación de usar /api/v1/pagos-externos, que ya usa Bearer + IP, para integraciones nuevas. Co-Authored-By: Claude Sonnet 5 --- docs/api-v1-contrato.html | 505 ++++++++++++++++++++++++++++++++++++++ docs/api-v1-contrato.md | 217 ++++++++++++++++ 2 files changed, 722 insertions(+) create mode 100644 docs/api-v1-contrato.html create mode 100644 docs/api-v1-contrato.md diff --git a/docs/api-v1-contrato.html b/docs/api-v1-contrato.html new file mode 100644 index 0000000..290f4cf --- /dev/null +++ b/docs/api-v1-contrato.html @@ -0,0 +1,505 @@ +Contrato API v1 + + +
+ +

Referencia de integración · admin.u-site.app

+

Contrato real de la API v1

+

+ Las 10 preguntas del equipo integrador, respondidas leyendo el código del + servidor en vez de inferirlas desde el cliente. Cada respuesta cita archivo y + línea para que se pueda auditar. Una es crítica y explica por qué la integración + probablemente nunca autenticó. +

+ +
+
1crítica
+
2requieren atención
+
7contrato confirmado
+
4arreglos de nuestro lado
+
+ +

+ Base de todos los endpoints: https://admin.u-site.app/api/v1/…
+ Montaje: rest/routes/routes.go:10 monta /api · rest/routes/api.go:38 agrega v1 +

+ + +
+
01
+
+ Su lectura es correcta +

expires_in es un timestamp Unix absoluto

+

El campo se reusa: entra como duración en segundos y sale como timestamp.

+
// config/token.go:26-49
+t.Expire = ninetyYears                    // duración, en segundos
+expiresIn := time.Now().Add(
+    time.Duration(t.Expire)*time.Second).Unix()
+claims["exp"] = expiresIn
+t.Expire = expiresIn                     // ← se SOBREESCRIBE
+

Lo que viaja en la respuesta es expiresIn: segundos desde epoch.

+
+

Pero hay algo más grave. Login llama a + CreateToken sin pasarle vencimiento (pkg/auth/user.go:98), + así que aplica el default: 90 años.

+

En la práctica ese token nunca expira, y toda la lógica de caché y renovación + del cliente no se ejerce jamás. Es un arreglo nuestro, no de ustedes.

+
+
+
+ + +
+
02
+
+ Contrato confirmado +

Respuesta de POST /oauth/token

+
{
+  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+  "expires_in": 4626547200
+}
+

rest/controllers/api/auth_controller.go:42-45

+
    +
  • El campo es token, no access_token.
  • +
  • expires_in está en el nivel raíz.
  • +
  • No hay token_type, ni refresh_token, ni envoltorio.
  • +
+

Error de credenciales, siempre HTTP 401:

+
{ "error": true, "message": "Invalid Credentials" }
+
+
+ + +
+
03
+
+ Plano, con una excepción +

QR y VCF devuelven JSON sin envoltorio

+
+ + + + + + + + + + +
EndpointMétodoRespuesta
/generate-qr-tmpPOST{"url": "https://…"}
/generate-url-qrPOST{"url": "https://…"}
/generate-vcfPOST{"success": true, "url": "https://…"}
/generate-qrPOSTNo es JSON — imagen binaria image/webp
+
+

vcard_qr.go:133-135 · vcard_vcf.go:103-106 · vcard_qr.go:169-170

+

Ojo con el último: generate-qr responde bytes de imagen. Un + json_decode sobre eso falla siempre.

+

Errores: {"error": "Datos inválidos"} con 400, o + {"error": "<detalle>"} con 500.

+
+
+ + +
+
04
+
+ Sí, el envoltorio raro es real +

dLocal devuelve JSON codificado como string

+

Nadie lo adivinó mal. La doble codificación existe:

+
// rest/controllers/api/dlocal_controller.go:42-45
+return c.Status(200).JSON(fiber.Map{
+    "message":  "Plan creado exitosamente.",
+    "response": string(response),   // ← el JSON, como STRING
+})
+

Lo que llega:

+
{
+  "message": "Plan creado exitosamente.",
+  "response": "{\"id\":\"PLAN-123\",\"status\":\"active\"}"
+}
+

Hay que hacer json_decode dos veces: una del cuerpo y + otra del campo response. Aplica igual a los 7 endpoints dLocal + (líneas 44, 77, 110, 137, 162, 188, 221).

+

El texto de message cambia según el endpoint, así que no conviene + usarlo para decidir nada.

+
+
+ + +
+
05
+
+ Hay dos textos, no uno +

El reintento automático cubre solo la mitad de los casos

+
// rest/middlewares/auth.go:271-286
+token := c.Cookies("Verify-Rest-Token")
+if token == "" {
+    return c.Status(401).JSON("Token not found")   // falta la cookie
+}
+… ErrorHandler:
+    return ctx.Status(401).JSON("Invalid Attempt")  // cookie inválida o vencida
+

Los dos son JSON string, con comillas incluidas — el cuerpo + literal es "Invalid Attempt", 16 bytes, no el texto pelado.

+
+

Recomendación: reintentar ante cualquier 401, sin mirar el + texto. Es más robusto y no depende de una redacción que puede cambiar.

+
+
+
+ + +
+
06
+
+ Una sola raíz +

No hay hosts separados por grupo

+

Todo cuelga de https://admin.u-site.app/api/v1/. Lo correcto es + guardar la raíz y concatenar la ruta, en vez de guardar la URL del + token y derivar las demás con str_replace.

+
POST   /api/v1/oauth/token
+POST   /api/v1/generate-qr-tmp
+POST   /api/v1/generate-qr                     (imagen)
+POST   /api/v1/generate-vcf
+POST   /api/v1/generate-url-qr
+POST   /api/v1/dlocal/subscription/crear-plan
+GET    /api/v1/dlocal/subscription/ver-plan/:planID
+PATCH  /api/v1/dlocal/subscription/actualizar-plan/:planID
+GET    /api/v1/dlocal/subscription/plan/all
+PATCH  /api/v1/dlocal/subscription/plan/:planId/subscription/:subscriptionId/deactivate
+GET    /api/v1/dlocal/subscription/:subscriptionId/execution/:invoiceId
+POST   /api/v1/dlocal/payment/crear-pago
+POST   /api/v1/rapyd/wallet/create
+
+
+ + +
+
07
+
+ Crítico +

El header Cookie es obligatorio, y hoy está roto

+

La API no acepta Bearer. Autentica exclusivamente por cookie:

+
// rest/middlewares/auth.go:272-281
+token := c.Cookies("Verify-Rest-Token")
+if token == "" { return c.Status(401).JSON("Token not found") }
+TokenLookup: "cookie:Verify-Rest-Token"
+
+

Con el placeholder literal ... sin completar, los 12 + endpoints autenticados devuelven 401. Si algo funciona hoy, es porque el + cliente HTTP tiene cookie jar y reusa la cookie que POST /oauth/token + deja seteada en la respuesta (config/token.go:40-47).

+
+
    +
  • Mandar el token en el body o como Authorization: Bearer + no autentica nada.
  • +
  • session_id no hace falta: el middleware no lo + mira. Se puede quitar.
  • +
+

Lo correcto: tomar el token de la respuesta de oauth/token + y mandarlo como Cookie: Verify-Rest-Token=<token>, o dejar que el + cliente maneje cookies solo.

+
+
+ + +
+
08
+
+ No aplica +

No hay control por IP en /api/v1

+

Solo se valida la cookie JWT. No hay allowlist; no hace falta registrar la IP + del servidor de producción.

+

Sí existe validación por IP, pero en otra API: + /api/v2 usa ApiKey con IP obligatoria y scopes, y + /api/v1/pagos-externos usa AuthServicioPago — ambas + excluidas de AuthApi (auth.go:266-268).

+
+

Si prefieren un esquema con IP fija y token que no viva en una cookie, + /api/v2 es el camino, y conviene migrar ahí en vez de + arreglar el actual.

+
+
+
+ + +
+
09
+
+ Fuera de este documento +

Credenciales

+

No van acá. Son las de un usuario real de la tabla users + (login.CheckLogin(), auth_controller.go:28). Que se + entreguen por un canal seguro.

+

Dado que llevan 15 meses sin rotar, conviene crear un usuario dedicado a + la integración en vez de reusar el de una persona.

+
+
+ + +
+
10
+
+ Existen, pero no para esto +

No hay callback saliente hacia ustedes

+

Hay webhooks entrantes de pago ya montados + (rest/routes/publicas.go:22-33): /webhooks/bold, + /webhooks/dlocal, /webhooks/paypal, más Coolify y Telegram.

+

Pero sirven para que la pasarela nos avise a nosotros, no para + avisarle a un tercero. Un callback hacia su sistema es desarrollo nuevo.

+

Mientras tanto, la alternativa ya construida es + /api/v1/pagos-externos, pensada justo para apps de terceros:

+
GET  /api/v1/pagos-externos/pasarelas
+POST /api/v1/pagos-externos/solicitar
+GET  /api/v1/pagos-externos/:referencia/estado
+
+

Usa token Bearer + IP (no cookie), así que evita todo el problema del punto 07. + Para una integración nueva, recomendamos esta y no los endpoints + dLocal directos.

+
+
+
+ +
+

Pendientes de nuestro lado

+

No son preguntas: son cosas a corregir en admin.u-site.app.

+
    +
  1. Tokens de 90 años (config/token.go:26). Un token + filtrado es acceso permanente. Ponerle un vencimiento razonable — y recién ahí la + caché y el reintento del cliente van a tener sentido.
  2. +
  3. La cookie se emite con Secure: false + (config/token.go:44), así que viaja también por HTTP plano.
  4. +
  5. Autenticación por cookie en una API máquina-a-máquina es el + problema de fondo: obliga a manejar cookie jar y no permite allowlist por IP. + /api/v2 ya lo resuelve bien.
  6. +
  7. La doble codificación de dLocal no aporta nada; devolver el + JSON anidado directo sería un cambio compatible si se versiona.
  8. +
+
+ +
diff --git a/docs/api-v1-contrato.md b/docs/api-v1-contrato.md new file mode 100644 index 0000000..4676f3f --- /dev/null +++ b/docs/api-v1-contrato.md @@ -0,0 +1,217 @@ +# Contrato real de la API v1 de `admin.u-site.app` + +Respuestas verificadas **leyendo el código del servidor**, no inferidas desde +el cliente. Cada punto cita el archivo y la línea para que se pueda auditar. + +Base de todos los endpoints: `https://admin.u-site.app/api/v1/…` +(`rest/routes/routes.go:10` monta `/api`, `rest/routes/api.go:38` agrega `v1`). + +--- + +## 1. Formato de `expires_in` → **timestamp Unix absoluto** + +Su interpretación es la correcta. En `config/token.go:22-49`: + +```go +t.Expire = ninetyYears // acá es duración en segundos +expiresIn := time.Now().Add(time.Duration(t.Expire)*time.Second).Unix() +claims["exp"] = expiresIn +t.Expire = expiresIn // ← se SOBREESCRIBE con el absoluto +``` + +El campo se reusa: entra como duración y sale como timestamp. Lo que viaja en +la respuesta es `expiresIn`, o sea **segundos desde epoch**. + +> **Pero hay algo más importante:** cuando `Login` llama a `CreateToken` no le +> pasa vencimiento (`pkg/auth/user.go:98`), así que aplica el default de +> `ninetyYears` — **90 años**. En la práctica el token de esa integración +> nunca expira, y toda la lógica de caché y renovación no se ejerce jamás. +> Esto hay que cambiarlo del lado nuestro (ver Pendientes). + +## 2. Respuesta real de `POST /api/v1/oauth/token` + +`rest/controllers/api/auth_controller.go:42-45`: + +```json +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expires_in": 4626547200 +} +``` + +- El campo es **`token`**, no `access_token`. +- `expires_in` está en el nivel raíz. +- No hay `token_type`, ni `refresh_token`, ni envoltorio. + +Errores (todos con HTTP 401), `auth_controller.go:15-40`: + +```json +{ "error": true, "message": "Invalid Credentials" } +``` + +## 3. QR y VCF → **JSON plano** + +| Endpoint | Método | Respuesta | +|---|---|---| +| `/api/v1/generate-qr-tmp` | POST | `{"url": "https://…"}` | +| `/api/v1/generate-url-qr` | POST | `{"url": "https://…"}` | +| `/api/v1/generate-vcf` | POST | `{"success": true, "url": "https://…"}` | +| `/api/v1/generate-qr` | POST | **no es JSON**: devuelve la imagen binaria `Content-Type: image/webp` | + +`vcard_qr.go:133-135`, `vcard_vcf.go:103-106`, `vcard_qr.go:169-170`. + +Ojo con el último: `generate-qr` responde bytes de imagen, no JSON. Si el +cliente hace `json_decode` de eso, falla siempre. + +Errores: `{"error": "Datos inválidos"}` con 400, o `{"error": ""}` con 500. + +## 4. dLocal → **sí, el envoltorio raro es real** + +Nadie lo adivinó mal. `rest/controllers/api/dlocal_controller.go:42-45`: + +```go +return c.Status(200).JSON(fiber.Map{ + "message": "Plan creado exitosamente.", + "response": string(response), // ← el JSON de dLocal, como STRING +}) +``` + +O sea, doble codificación real: + +```json +{ + "message": "Plan creado exitosamente.", + "response": "{\"id\":\"PLAN-123\",\"status\":\"active\"}" +} +``` + +Hay que hacer `json_decode` **dos veces**: una del cuerpo y otra del campo +`response`. Aplica igual a los 7 endpoints dLocal (líneas 44, 77, 110, 137, +162, 188, 221). El campo `message` cambia de texto según el endpoint, así que +no conviene usarlo para decidir nada. + +## 5. Cuerpo de error de sesión inválida → **hay DOS textos distintos** + +Acá está el problema que sospechaban. `rest/middlewares/auth.go:271-286`: + +```go +token := c.Cookies("Verify-Rest-Token") +if token == "" { + return c.Status(401).JSON("Token not found") // ← falta la cookie +} +… ErrorHandler: return ctx.Status(401).JSON("Invalid Attempt") // ← cookie inválida/vencida +``` + +- Sin cookie → cuerpo `"Token not found"` +- Cookie presente pero inválida o vencida → cuerpo `"Invalid Attempt"` + +Los dos son **JSON string, con comillas incluidas** — el cuerpo literal es +`"Invalid Attempt"`, 16 bytes, no el texto pelado. + +Su reintento solo cubre el segundo caso. **Recomendación: reintentar ante +cualquier 401**, sin mirar el texto. Es más robusto y no depende de una +redacción que puede cambiar. + +## 6. URLs base → **una sola raíz, sin derivar por `str_replace`** + +Todos cuelgan de `https://admin.u-site.app/api/v1/`. No hay hosts separados +por grupo, así que lo correcto es guardar **la raíz** y concatenar la ruta, +no guardar la URL del token y derivar las demás: + +``` +POST /api/v1/oauth/token +POST /api/v1/generate-qr-tmp +POST /api/v1/generate-qr (devuelve imagen) +POST /api/v1/generate-vcf +POST /api/v1/generate-url-qr +POST /api/v1/dlocal/subscription/crear-plan +GET /api/v1/dlocal/subscription/ver-plan/:planID +PATCH /api/v1/dlocal/subscription/actualizar-plan/:planID +GET /api/v1/dlocal/subscription/plan/all +PATCH /api/v1/dlocal/subscription/plan/:planId/subscription/:subscriptionId/deactivate +GET /api/v1/dlocal/subscription/:subscriptionId/execution/:invoiceId +POST /api/v1/dlocal/payment/crear-pago +POST /api/v1/rapyd/wallet/create +``` + +## 7. El header `Cookie` → **es OBLIGATORIO, y hoy está roto** + +Esta es la más crítica de las 10. + +La API **no acepta Bearer**. `AuthApi()` lee exclusivamente la cookie +(`rest/middlewares/auth.go:272-281`): + +```go +token := c.Cookies("Verify-Rest-Token") +if token == "" { return c.Status(401).JSON("Token not found") } +TokenLookup: "cookie:Verify-Rest-Token" +``` + +Consecuencias: + +- Mandar el token en el body o como `Authorization: Bearer` **no autentica nada**. +- Con el placeholder literal `...` sin completar, **los 12 endpoints + autenticados devuelven 401 "Token not found"**. Si algo de eso "funciona" + hoy, es porque el cliente HTTP tiene cookie jar y está reusando la cookie + que `POST /oauth/token` deja seteada en la respuesta (`config/token.go:40-47`). +- **`session_id` no hace falta**: el middleware no lo mira. Se puede quitar. + +Lo correcto es tomar el `token` de la respuesta de `oauth/token` y mandarlo +como `Cookie: Verify-Rest-Token=`, o dejar que el cliente maneje +cookies solo. + +## 8. Control de acceso por IP → **no, para `/api/v1` no hay** + +`/api/v1` solo valida la cookie JWT. No hay allowlist de IP; no hace falta +registrar la IP del servidor de producción. + +Sí existe validación por IP, pero en **otra** API: `/api/v2` usa `ApiKey` con +IP obligatoria y scopes, y `/api/v1/pagos-externos` usa `AuthServicioPago` +(ambas excluidas de `AuthApi` en `auth.go:266-268`). Si prefieren un esquema +con IP fija y token que no vive en una cookie, **`/api/v2` es el camino** y +vale la pena migrar ahí en vez de arreglar el actual. + +## 9. Usuario y contraseña + +No van en este documento. Son las credenciales de un usuario real de la tabla +`users` (`login.CheckLogin()`, `auth_controller.go:28`). Que las entreguen por +un canal seguro y, dado que llevan 15 meses sin rotar, **conviene crear un +usuario dedicado a la integración** en vez de reusar uno de persona. + +## 10. Webhooks → **sí existen, pero no para esto** + +Hay webhooks entrantes de pago ya montados (`rest/routes/publicas.go:22-33`): +`/webhooks/bold`, `/webhooks/dlocal`, `/webhooks/paypal`, más Coolify y +Telegram. + +Pero son para que **la pasarela nos avise a nosotros**, no para avisarle a un +tercero. Hoy **no existe** un callback saliente hacia el sistema de ustedes ni +para pagos ni para QR. Si lo necesitan, es desarrollo nuevo de nuestro lado. + +Mientras tanto, la alternativa ya construida es `/api/v1/pagos-externos` +(`api.go:32-34`), pensada justo para apps de terceros: + +``` +GET /api/v1/pagos-externos/pasarelas +POST /api/v1/pagos-externos/solicitar +GET /api/v1/pagos-externos/:referencia/estado +``` + +Usa token Bearer + IP (no cookie), así que evita todo el problema del punto 7. +**Para una integración nueva, recomendamos esta y no los endpoints dLocal +directos.** + +--- + +## Pendientes de nuestro lado (no son preguntas, son cosas a corregir) + +1. **Tokens de 90 años** (`config/token.go:26`). Un token filtrado es acceso + permanente. Hay que ponerle un vencimiento razonable — y recién ahí la + caché y el reintento del cliente van a tener sentido. +2. **La cookie se emite con `Secure: false`** (`config/token.go:44`), así que + viaja también por HTTP plano. +3. **Autenticación por cookie en una API máquina-a-máquina** es el problema de + fondo: obliga a manejar cookie jar y no permite IP allowlist. `/api/v2` ya + resuelve esto bien. +4. **La doble codificación de dLocal** (punto 4) no aporta nada; devolver el + JSON anidado directo sería un cambio compatible si se versiona.