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>
218 lines
8.2 KiB
Markdown
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.
|