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,505 @@
|
||||
<title>Contrato API v1</title>
|
||||
<style>
|
||||
/* ── Tokens ───────────────────────────────────────────────────────────────
|
||||
Paleta anclada al verde de marca de U-Site (#8eb02f) que ya usa el panel,
|
||||
el widget y los correos. Neutros con sesgo oliva para que el acento no
|
||||
flote sobre un gris ajeno. Los colores semánticos (crítico / atención /
|
||||
confirmado) son un juego aparte del acento, a propósito. */
|
||||
:root {
|
||||
--ground: #f6f6f2;
|
||||
--surface: #ffffff;
|
||||
--surface-sunk: #f0f1ea;
|
||||
--line: #e0e1d6;
|
||||
--line-strong: #c9cbba;
|
||||
--text: #1c1e18;
|
||||
--text-soft: #5a5d51;
|
||||
--text-faint: #86897a;
|
||||
--accent: #63801c;
|
||||
--accent-soft: #eef3dd;
|
||||
--crit: #a32a1e;
|
||||
--crit-soft: #fbe9e6;
|
||||
--warn: #8a5a06;
|
||||
--warn-soft: #fbf0d9;
|
||||
--ok: #2f6b3f;
|
||||
--ok-soft: #e6f1e6;
|
||||
--code-bg: #1a1c16;
|
||||
--code-text: #e8eadd;
|
||||
--code-dim: #8f947f;
|
||||
--shadow: 0 1px 2px rgba(28,30,24,.06), 0 8px 24px -12px rgba(28,30,24,.14);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--ground: #14150f;
|
||||
--surface: #1c1e17;
|
||||
--surface-sunk: #24261d;
|
||||
--line: #32352a;
|
||||
--line-strong: #474b3c;
|
||||
--text: #e9ebdf;
|
||||
--text-soft: #b0b4a2;
|
||||
--text-faint: #82866f;
|
||||
--accent: #a8c94f;
|
||||
--accent-soft: #2a3118;
|
||||
--crit: #f08a7c;
|
||||
--crit-soft: #38201c;
|
||||
--warn: #e5b45c;
|
||||
--warn-soft: #362a13;
|
||||
--ok: #7fc08d;
|
||||
--ok-soft: #1d2f22;
|
||||
--code-bg: #0e0f0a;
|
||||
--code-text: #e8eadd;
|
||||
--code-dim: #7b8069;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 10px 30px -14px rgba(0,0,0,.7);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--ground: #14150f;
|
||||
--surface: #1c1e17;
|
||||
--surface-sunk: #24261d;
|
||||
--line: #32352a;
|
||||
--line-strong: #474b3c;
|
||||
--text: #e9ebdf;
|
||||
--text-soft: #b0b4a2;
|
||||
--text-faint: #82866f;
|
||||
--accent: #a8c94f;
|
||||
--accent-soft: #2a3118;
|
||||
--crit: #f08a7c;
|
||||
--crit-soft: #38201c;
|
||||
--warn: #e5b45c;
|
||||
--warn-soft: #362a13;
|
||||
--ok: #7fc08d;
|
||||
--ok-soft: #1d2f22;
|
||||
--code-bg: #0e0f0a;
|
||||
--code-text: #e8eadd;
|
||||
--code-dim: #7b8069;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 10px 30px -14px rgba(0,0,0,.7);
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ground);
|
||||
color: var(--text);
|
||||
font-family: ui-sans-serif, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.wrap { max-width: 60rem; margin: 0 auto; padding: 3rem 1.5rem 6rem; }
|
||||
|
||||
/* ── Encabezado ─────────────────────────────────────────────────────────── */
|
||||
.eyebrow {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .6875rem; letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--accent); margin: 0 0 .75rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: clamp(1.9rem, 1.3rem + 2.2vw, 2.75rem);
|
||||
line-height: 1.1; letter-spacing: -.022em; font-weight: 660;
|
||||
margin: 0 0 .85rem; text-wrap: balance;
|
||||
}
|
||||
.standfirst {
|
||||
font-size: 1.0625rem; color: var(--text-soft);
|
||||
max-width: 46rem; margin: 0 0 2rem;
|
||||
}
|
||||
.standfirst strong { color: var(--text); font-weight: 620; }
|
||||
|
||||
/* ── Resumen ────────────────────────────────────────────────────────────── */
|
||||
.tally { display: flex; flex-wrap: wrap; gap: .625rem; margin-bottom: 1rem; }
|
||||
.tally-item {
|
||||
display: flex; align-items: baseline; gap: .5rem;
|
||||
background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: .5rem; padding: .625rem .875rem; box-shadow: var(--shadow);
|
||||
}
|
||||
.tally-n {
|
||||
font-size: 1.375rem; font-weight: 680; line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tally-l { font-size: .8125rem; color: var(--text-soft); }
|
||||
.tally-item.is-crit { border-color: var(--crit); }
|
||||
.tally-item.is-crit .tally-n { color: var(--crit); }
|
||||
.tally-item.is-warn .tally-n { color: var(--warn); }
|
||||
.tally-item.is-ok .tally-n { color: var(--ok); }
|
||||
|
||||
.meta {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .75rem; color: var(--text-faint);
|
||||
border-top: 1px solid var(--line); padding-top: 1rem; margin-bottom: 2.75rem;
|
||||
}
|
||||
.meta code { background: none; padding: 0; color: var(--text-soft); }
|
||||
|
||||
/* ── Secciones numeradas ────────────────────────────────────────────────
|
||||
La numeración no es decorativa: el otro equipo mandó las preguntas
|
||||
numeradas del 1 al 10 y así se responden en el mismo orden. */
|
||||
.q {
|
||||
display: grid; grid-template-columns: 3.25rem 1fr; gap: 0 1.25rem;
|
||||
padding: 1.75rem 0; border-top: 1px solid var(--line);
|
||||
}
|
||||
.q-num {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .875rem; font-weight: 600; color: var(--text-faint);
|
||||
font-variant-numeric: tabular-nums; padding-top: .3rem;
|
||||
}
|
||||
.q-body { min-width: 0; }
|
||||
.q h2 {
|
||||
font-size: 1.1875rem; line-height: 1.3; letter-spacing: -.012em;
|
||||
font-weight: 640; margin: 0 0 .6rem; text-wrap: balance;
|
||||
}
|
||||
.q p { margin: 0 0 .85rem; }
|
||||
.q p:last-child { margin-bottom: 0; }
|
||||
.q ul { margin: 0 0 .85rem; padding-left: 1.15rem; }
|
||||
.q li { margin-bottom: .3rem; }
|
||||
|
||||
/* Barra de severidad a la izquierda del número */
|
||||
.q.sev-crit .q-num { color: var(--crit); }
|
||||
.q.sev-warn .q-num { color: var(--warn); }
|
||||
.q.sev-ok .q-num { color: var(--ok); }
|
||||
|
||||
.chip {
|
||||
display: inline-block; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .6875rem; letter-spacing: .06em; text-transform: uppercase;
|
||||
padding: .2rem .5rem; border-radius: .3125rem; margin-bottom: .55rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.chip-crit { background: var(--crit-soft); color: var(--crit); }
|
||||
.chip-warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.chip-ok { background: var(--ok-soft); color: var(--ok); }
|
||||
.chip-info { background: var(--surface-sunk); color: var(--text-soft); }
|
||||
|
||||
/* ── Código ─────────────────────────────────────────────────────────────── */
|
||||
pre {
|
||||
background: var(--code-bg); color: var(--code-text);
|
||||
border-radius: .5rem; padding: .875rem 1rem; margin: 0 0 .85rem;
|
||||
overflow-x: auto; font-size: .8125rem; line-height: 1.55;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
pre .c { color: var(--code-dim); }
|
||||
pre .hl { color: #d9e88a; }
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .875em; background: var(--surface-sunk);
|
||||
padding: .1rem .3rem; border-radius: .25rem;
|
||||
}
|
||||
|
||||
.cite {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .75rem; color: var(--text-faint); margin: -.35rem 0 .85rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
border-left: 2px solid var(--accent); background: var(--accent-soft);
|
||||
padding: .75rem .9rem; border-radius: 0 .375rem .375rem 0;
|
||||
margin: 0 0 .85rem; font-size: .9375rem;
|
||||
}
|
||||
.note.is-crit { border-left-color: var(--crit); background: var(--crit-soft); }
|
||||
.note p { margin: 0; }
|
||||
.note p + p { margin-top: .5rem; }
|
||||
|
||||
/* ── Tablas ─────────────────────────────────────────────────────────────── */
|
||||
.scroll { overflow-x: auto; margin: 0 0 .85rem; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: .875rem; min-width: 32rem; }
|
||||
th, td { text-align: left; padding: .5rem .7rem; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||
th {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .6875rem; letter-spacing: .07em; text-transform: uppercase;
|
||||
color: var(--text-faint); font-weight: 600;
|
||||
border-bottom-color: var(--line-strong);
|
||||
}
|
||||
td code { font-size: .8125rem; }
|
||||
|
||||
/* ── Cierre ─────────────────────────────────────────────────────────────── */
|
||||
.todo {
|
||||
margin-top: 3rem; background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: .625rem; padding: 1.5rem 1.75rem; box-shadow: var(--shadow);
|
||||
}
|
||||
.todo h2 { font-size: 1.125rem; font-weight: 640; margin: 0 0 .35rem; letter-spacing: -.01em; }
|
||||
.todo > p { color: var(--text-soft); font-size: .9375rem; margin: 0 0 1.1rem; }
|
||||
.todo ol { margin: 0; padding-left: 1.15rem; }
|
||||
.todo li { margin-bottom: .7rem; }
|
||||
.todo li:last-child { margin-bottom: 0; }
|
||||
.todo li strong { font-weight: 620; }
|
||||
|
||||
a { color: var(--accent); text-underline-offset: 2px; }
|
||||
a:focus-visible, [tabindex]:focus-visible {
|
||||
outline: 2px solid var(--accent); outline-offset: 2px; border-radius: .2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 34rem) {
|
||||
.q { grid-template-columns: 1fr; gap: 0; }
|
||||
.q-num { padding-top: 0; margin-bottom: .35rem; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<p class="eyebrow">Referencia de integración · admin.u-site.app</p>
|
||||
<h1>Contrato real de la API v1</h1>
|
||||
<p class="standfirst">
|
||||
Las 10 preguntas del equipo integrador, respondidas <strong>leyendo el código del
|
||||
servidor</strong> 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ó.
|
||||
</p>
|
||||
|
||||
<div class="tally">
|
||||
<div class="tally-item is-crit"><span class="tally-n">1</span><span class="tally-l">crítica</span></div>
|
||||
<div class="tally-item is-warn"><span class="tally-n">2</span><span class="tally-l">requieren atención</span></div>
|
||||
<div class="tally-item is-ok"><span class="tally-n">7</span><span class="tally-l">contrato confirmado</span></div>
|
||||
<div class="tally-item"><span class="tally-n">4</span><span class="tally-l">arreglos de nuestro lado</span></div>
|
||||
</div>
|
||||
|
||||
<p class="meta">
|
||||
Base de todos los endpoints: <code>https://admin.u-site.app/api/v1/…</code><br>
|
||||
Montaje: <code>rest/routes/routes.go:10</code> monta <code>/api</code> · <code>rest/routes/api.go:38</code> agrega <code>v1</code>
|
||||
</p>
|
||||
|
||||
<!-- 1 -->
|
||||
<section class="q sev-ok">
|
||||
<div class="q-num">01</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-ok">Su lectura es correcta</span>
|
||||
<h2><code>expires_in</code> es un timestamp Unix absoluto</h2>
|
||||
<p>El campo se reusa: entra como duración en segundos y sale como timestamp.</p>
|
||||
<pre><span class="c">// config/token.go:26-49</span>
|
||||
t.Expire = ninetyYears <span class="c">// duración, en segundos</span>
|
||||
expiresIn := time.Now().Add(
|
||||
time.Duration(t.Expire)*time.Second).Unix()
|
||||
claims["exp"] = expiresIn
|
||||
t.Expire = <span class="hl">expiresIn</span> <span class="c">// ← se SOBREESCRIBE</span></pre>
|
||||
<p>Lo que viaja en la respuesta es <code>expiresIn</code>: segundos desde epoch.</p>
|
||||
<div class="note is-crit">
|
||||
<p><strong>Pero hay algo más grave.</strong> <code>Login</code> llama a
|
||||
<code>CreateToken</code> sin pasarle vencimiento (<code>pkg/auth/user.go:98</code>),
|
||||
así que aplica el default: <strong>90 años</strong>.</p>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2 -->
|
||||
<section class="q sev-ok">
|
||||
<div class="q-num">02</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-ok">Contrato confirmado</span>
|
||||
<h2>Respuesta de <code>POST /oauth/token</code></h2>
|
||||
<pre>{
|
||||
"<span class="hl">token</span>": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"<span class="hl">expires_in</span>": 4626547200
|
||||
}</pre>
|
||||
<p class="cite">rest/controllers/api/auth_controller.go:42-45</p>
|
||||
<ul>
|
||||
<li>El campo es <code>token</code>, <strong>no</strong> <code>access_token</code>.</li>
|
||||
<li><code>expires_in</code> está en el nivel raíz.</li>
|
||||
<li>No hay <code>token_type</code>, ni <code>refresh_token</code>, ni envoltorio.</li>
|
||||
</ul>
|
||||
<p>Error de credenciales, siempre HTTP 401:</p>
|
||||
<pre>{ "error": true, "message": "Invalid Credentials" }</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3 -->
|
||||
<section class="q sev-warn">
|
||||
<div class="q-num">03</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-warn">Plano, con una excepción</span>
|
||||
<h2>QR y VCF devuelven JSON sin envoltorio</h2>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Endpoint</th><th>Método</th><th>Respuesta</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>/generate-qr-tmp</code></td><td>POST</td><td><code>{"url": "https://…"}</code></td></tr>
|
||||
<tr><td><code>/generate-url-qr</code></td><td>POST</td><td><code>{"url": "https://…"}</code></td></tr>
|
||||
<tr><td><code>/generate-vcf</code></td><td>POST</td><td><code>{"success": true, "url": "https://…"}</code></td></tr>
|
||||
<tr><td><code>/generate-qr</code></td><td>POST</td><td><strong>No es JSON</strong> — imagen binaria <code>image/webp</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="cite">vcard_qr.go:133-135 · vcard_vcf.go:103-106 · vcard_qr.go:169-170</p>
|
||||
<p>Ojo con el último: <code>generate-qr</code> responde bytes de imagen. Un
|
||||
<code>json_decode</code> sobre eso falla siempre.</p>
|
||||
<p>Errores: <code>{"error": "Datos inválidos"}</code> con 400, o
|
||||
<code>{"error": "<detalle>"}</code> con 500.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4 -->
|
||||
<section class="q sev-ok">
|
||||
<div class="q-num">04</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-ok">Sí, el envoltorio raro es real</span>
|
||||
<h2>dLocal devuelve JSON codificado como string</h2>
|
||||
<p>Nadie lo adivinó mal. La doble codificación existe:</p>
|
||||
<pre><span class="c">// rest/controllers/api/dlocal_controller.go:42-45</span>
|
||||
return c.Status(200).JSON(fiber.Map{
|
||||
"message": "Plan creado exitosamente.",
|
||||
"response": <span class="hl">string(response)</span>, <span class="c">// ← el JSON, como STRING</span>
|
||||
})</pre>
|
||||
<p>Lo que llega:</p>
|
||||
<pre>{
|
||||
"message": "Plan creado exitosamente.",
|
||||
"response": "<span class="hl">{\"id\":\"PLAN-123\",\"status\":\"active\"}</span>"
|
||||
}</pre>
|
||||
<p>Hay que hacer <code>json_decode</code> <strong>dos veces</strong>: una del cuerpo y
|
||||
otra del campo <code>response</code>. Aplica igual a los 7 endpoints dLocal
|
||||
(líneas 44, 77, 110, 137, 162, 188, 221).</p>
|
||||
<p>El texto de <code>message</code> cambia según el endpoint, así que no conviene
|
||||
usarlo para decidir nada.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 5 -->
|
||||
<section class="q sev-warn">
|
||||
<div class="q-num">05</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-warn">Hay dos textos, no uno</span>
|
||||
<h2>El reintento automático cubre solo la mitad de los casos</h2>
|
||||
<pre><span class="c">// rest/middlewares/auth.go:271-286</span>
|
||||
token := c.Cookies("Verify-Rest-Token")
|
||||
if token == "" {
|
||||
return c.Status(401).JSON(<span class="hl">"Token not found"</span>) <span class="c">// falta la cookie</span>
|
||||
}
|
||||
… ErrorHandler:
|
||||
return ctx.Status(401).JSON(<span class="hl">"Invalid Attempt"</span>) <span class="c">// cookie inválida o vencida</span></pre>
|
||||
<p>Los dos son <strong>JSON string, con comillas incluidas</strong> — el cuerpo
|
||||
literal es <code>"Invalid Attempt"</code>, 16 bytes, no el texto pelado.</p>
|
||||
<div class="note">
|
||||
<p><strong>Recomendación:</strong> reintentar ante cualquier 401, sin mirar el
|
||||
texto. Es más robusto y no depende de una redacción que puede cambiar.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 6 -->
|
||||
<section class="q sev-ok">
|
||||
<div class="q-num">06</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-ok">Una sola raíz</span>
|
||||
<h2>No hay hosts separados por grupo</h2>
|
||||
<p>Todo cuelga de <code>https://admin.u-site.app/api/v1/</code>. Lo correcto es
|
||||
guardar <strong>la raíz</strong> y concatenar la ruta, en vez de guardar la URL del
|
||||
token y derivar las demás con <code>str_replace</code>.</p>
|
||||
<pre>POST /api/v1/oauth/token
|
||||
POST /api/v1/generate-qr-tmp
|
||||
POST /api/v1/generate-qr <span class="c">(imagen)</span>
|
||||
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</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 7 -->
|
||||
<section class="q sev-crit">
|
||||
<div class="q-num">07</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-crit">Crítico</span>
|
||||
<h2>El header <code>Cookie</code> es obligatorio, y hoy está roto</h2>
|
||||
<p>La API <strong>no acepta Bearer</strong>. Autentica exclusivamente por cookie:</p>
|
||||
<pre><span class="c">// rest/middlewares/auth.go:272-281</span>
|
||||
token := c.Cookies(<span class="hl">"Verify-Rest-Token"</span>)
|
||||
if token == "" { return c.Status(401).JSON("Token not found") }
|
||||
TokenLookup: <span class="hl">"cookie:Verify-Rest-Token"</span></pre>
|
||||
<div class="note is-crit">
|
||||
<p>Con el placeholder literal <code>...</code> sin completar, <strong>los 12
|
||||
endpoints autenticados devuelven 401</strong>. Si algo funciona hoy, es porque el
|
||||
cliente HTTP tiene cookie jar y reusa la cookie que <code>POST /oauth/token</code>
|
||||
deja seteada en la respuesta (<code>config/token.go:40-47</code>).</p>
|
||||
</div>
|
||||
<ul>
|
||||
<li>Mandar el token en el body o como <code>Authorization: Bearer</code>
|
||||
<strong>no autentica nada</strong>.</li>
|
||||
<li><code>session_id</code> <strong>no hace falta</strong>: el middleware no lo
|
||||
mira. Se puede quitar.</li>
|
||||
</ul>
|
||||
<p>Lo correcto: tomar el <code>token</code> de la respuesta de <code>oauth/token</code>
|
||||
y mandarlo como <code>Cookie: Verify-Rest-Token=<token></code>, o dejar que el
|
||||
cliente maneje cookies solo.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 8 -->
|
||||
<section class="q sev-ok">
|
||||
<div class="q-num">08</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-info">No aplica</span>
|
||||
<h2>No hay control por IP en <code>/api/v1</code></h2>
|
||||
<p>Solo se valida la cookie JWT. No hay allowlist; no hace falta registrar la IP
|
||||
del servidor de producción.</p>
|
||||
<p>Sí existe validación por IP, pero en <strong>otra</strong> API:
|
||||
<code>/api/v2</code> usa <code>ApiKey</code> con IP obligatoria y scopes, y
|
||||
<code>/api/v1/pagos-externos</code> usa <code>AuthServicioPago</code> — ambas
|
||||
excluidas de <code>AuthApi</code> (<code>auth.go:266-268</code>).</p>
|
||||
<div class="note">
|
||||
<p>Si prefieren un esquema con IP fija y token que no viva en una cookie,
|
||||
<strong><code>/api/v2</code> es el camino</strong>, y conviene migrar ahí en vez de
|
||||
arreglar el actual.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 9 -->
|
||||
<section class="q sev-warn">
|
||||
<div class="q-num">09</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-warn">Fuera de este documento</span>
|
||||
<h2>Credenciales</h2>
|
||||
<p>No van acá. Son las de un usuario real de la tabla <code>users</code>
|
||||
(<code>login.CheckLogin()</code>, <code>auth_controller.go:28</code>). Que se
|
||||
entreguen por un canal seguro.</p>
|
||||
<p>Dado que llevan 15 meses sin rotar, conviene <strong>crear un usuario dedicado a
|
||||
la integración</strong> en vez de reusar el de una persona.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 10 -->
|
||||
<section class="q sev-ok">
|
||||
<div class="q-num">10</div>
|
||||
<div class="q-body">
|
||||
<span class="chip chip-info">Existen, pero no para esto</span>
|
||||
<h2>No hay callback saliente hacia ustedes</h2>
|
||||
<p>Hay webhooks entrantes de pago ya montados
|
||||
(<code>rest/routes/publicas.go:22-33</code>): <code>/webhooks/bold</code>,
|
||||
<code>/webhooks/dlocal</code>, <code>/webhooks/paypal</code>, más Coolify y Telegram.</p>
|
||||
<p>Pero sirven para que <strong>la pasarela nos avise a nosotros</strong>, no para
|
||||
avisarle a un tercero. Un callback hacia su sistema es desarrollo nuevo.</p>
|
||||
<p>Mientras tanto, la alternativa ya construida es
|
||||
<code>/api/v1/pagos-externos</code>, pensada justo para apps de terceros:</p>
|
||||
<pre>GET /api/v1/pagos-externos/pasarelas
|
||||
POST /api/v1/pagos-externos/solicitar
|
||||
GET /api/v1/pagos-externos/:referencia/estado</pre>
|
||||
<div class="note">
|
||||
<p>Usa token Bearer + IP (no cookie), así que evita todo el problema del punto 07.
|
||||
<strong>Para una integración nueva, recomendamos esta</strong> y no los endpoints
|
||||
dLocal directos.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="todo">
|
||||
<h2>Pendientes de nuestro lado</h2>
|
||||
<p>No son preguntas: son cosas a corregir en <code>admin.u-site.app</code>.</p>
|
||||
<ol>
|
||||
<li><strong>Tokens de 90 años</strong> (<code>config/token.go:26</code>). 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.</li>
|
||||
<li><strong>La cookie se emite con <code>Secure: false</code></strong>
|
||||
(<code>config/token.go:44</code>), así que viaja también por HTTP plano.</li>
|
||||
<li><strong>Autenticación por cookie en una API máquina-a-máquina</strong> es el
|
||||
problema de fondo: obliga a manejar cookie jar y no permite allowlist por IP.
|
||||
<code>/api/v2</code> ya lo resuelve bien.</li>
|
||||
<li><strong>La doble codificación de dLocal</strong> no aporta nada; devolver el
|
||||
JSON anidado directo sería un cambio compatible si se versiona.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -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