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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8ef49c5169
commit
ff4237eced
@@ -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": "<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.
|
||||
Reference in New Issue
Block a user