Files
soft_usite/docs/api-v1-contrato.md
T
Lizandro GuarnizoandClaude Sonnet 5 ff4237eced docs: contrato real de la API v1 para el equipo integrador
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 <noreply@anthropic.com>
2026-08-15 02:02:59 -05:00

218 lines
8.2 KiB
Markdown

# 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": "<detalle>"}` 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=<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.